您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Collects barcodes automatically from a table, timestamps them, and provides a viewing interface.
当前为
// ==UserScript== // @name Pending barcodes // @namespace http://tampermonkey.net/ // @version 1.9 // @description Collects barcodes automatically from a table, timestamps them, and provides a viewing interface. // @author Hamad AlShegifi // @match *://his.kaauh.org/lab/* // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @license MIT // ==/UserScript== (function() { 'use strict'; // --- Configuration --- const TABLE_BODY_SELECTOR = 'tbody[formarrayname="TubeTypeList"]'; const MENU_INJECTION_SELECTOR = 'span.csi-menu-text[title="Documents"]'; const BARCODE_DISPLAY_SELECTOR = '#barcode-display-box'; // The ID of the box displaying the current patient's barcode const STORAGE_KEY = 'collectedBarcodes_storage'; // --- State Flags & Cache --- let isMenuViewAdded = false; const collectedBarcodesThisSession = new Set(); let lastCheckedPatientBarcode = null; // Caches the barcode currently displayed on the page // --- Main Logic --- // Function to initialize the script and set up a single, persistent observer function initialize() { console.log("Barcode Collector: Script started. Observing for page changes..."); // Use a single MutationObserver to watch for all dynamically loaded elements const observer = new MutationObserver((mutations, obs) => { const documentsMenu = document.querySelector(MENU_INJECTION_SELECTOR); const patientBarcodeBox = document.querySelector(BARCODE_DISPLAY_SELECTOR); // Add the custom menu item (runs only once) if (documentsMenu && !isMenuViewAdded) { console.log("Barcode Collector: 'Documents' menu found. Adding custom view menu."); addMenuView(documentsMenu); isMenuViewAdded = true; } // --- Check for the patient barcode display --- const barcodeOnPage = patientBarcodeBox ? Array.from(patientBarcodeBox.querySelectorAll('div')).find(div => div.textContent.includes('Sample Barcode:'))?.nextElementSibling?.textContent.trim() : null; if (barcodeOnPage) { // If a new barcode is detected on the page, update the cache and the view if (barcodeOnPage !== lastCheckedPatientBarcode) { console.log(`Barcode Collector: Detected patient barcode on page: ${barcodeOnPage}`); lastCheckedPatientBarcode = barcodeOnPage; markBarcodeAsFoundAndUpdateStorage(barcodeOnPage); } } else { // Reset when the user navigates away from the page if (lastCheckedPatientBarcode !== null) { lastCheckedPatientBarcode = null; } } // --- New Robust Method: Scan all barcodes in the table on every change --- const allBarcodeInputs = document.querySelectorAll(`${TABLE_BODY_SELECTOR} input[formcontrolname="Barcode"]`); if (allBarcodeInputs.length > 0) { // Use an async IIFE with a for...of loop to handle the async saveBarcode function correctly. // This prevents a race condition where multiple saves happen at once and only the last one succeeds. (async () => { for (const input of allBarcodeInputs) { if (input.value) { await saveBarcode(input.value.trim()); } } })(); } }); // Observe the entire document body for any changes observer.observe(document.body, { childList: true, subtree: true }); } // Function to save a new barcode to storage, with duplicate checking async function saveBarcode(barcode) { // First, check our in-memory set for this session. This is the fastest way to reject duplicates. if (collectedBarcodesThisSession.has(barcode)) { return; } // If not in memory, check persistent storage to avoid adding duplicates from previous sessions. let barcodes = await GM_getValue(STORAGE_KEY, []); if (barcodes.some(entry => entry.barcode === barcode)) { collectedBarcodesThisSession.add(barcode); // Add to session cache to prevent future storage checks return; } // If the barcode is truly new, add it. const newEntry = { count: barcodes.length + 1, barcode: barcode, timestamp: new Date().toISOString(), found: false // Initialize the found status }; barcodes.push(newEntry); await GM_setValue(STORAGE_KEY, barcodes); // Save the updated array to storage collectedBarcodesThisSession.add(barcode); // Add to session cache console.log(`Barcode Collector: Saved new barcode - ${barcode}`); } // Function to create and inject the "View Barcodes" menu item function addMenuView(targetMenuElement) { // Find the main container of the "Documents" menu item to ensure proper alignment const documentsContainer = targetMenuElement.closest('csi-main-menu')?.parentElement?.parentElement; if (!documentsContainer) { console.error("Barcode Collector: Could not find the main menu item container to inject into. The page structure may have changed."); return; } // Create a new container that is the same type as the one we found. const newContainer = document.createElement(documentsContainer.tagName); // Set the inner HTML to replicate the structure of the existing menu items, // ensuring it has an icon holder and a text wrapper. newContainer.innerHTML = ` <div class="${documentsContainer.firstElementChild.className}"> <csi-main-menu class="${targetMenuElement.closest('csi-main-menu').className}"> <a style="cursor: pointer;"> <span class="icon-holder csi-menu-icon"> <i class="fa fa-list-ul" aria-hidden="true"></i> </span> <div class="csi-menu-text-wrapper"> <span class="csi-menu-text sidemenu-title" title="Pending Barcodes">Pending Barcodes</span> </div> </a> </csi-main-menu> </div> `; // Add a click listener to the `<a>` tag within our newly created element. newContainer.querySelector('a').addEventListener('click', showBarcodesModal); // Insert the new menu item into the DOM right after the "Documents" menu item. documentsContainer.parentNode.insertBefore(newContainer, documentsContainer.nextSibling); } // --- Helper Function for Time Formatting --- function formatTimeSince(isoTimestamp) { const date = new Date(isoTimestamp); const now = new Date(); const seconds = Math.floor((now - date) / 1000); if (seconds < 60) return `${seconds} seconds ago`; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes} minutes ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours} hours ago`; const days = Math.floor(hours / 24); return `${days} days ago`; } // --- NEW: Function to update storage and UI when a barcode is found --- async function markBarcodeAsFoundAndUpdateStorage(barcodeToMark) { let barcodes = await GM_getValue(STORAGE_KEY, []); const entry = barcodes.find(b => b.barcode === barcodeToMark); // Only update if the entry exists and is not already marked as found if (entry && !entry.found) { entry.found = true; await GM_setValue(STORAGE_KEY, barcodes); console.log(`Barcode Collector: Marked barcode ${barcodeToMark} as found and saved status.`); } // Always try to update the UI in case the modal is open updateViewWithCheckmark(barcodeToMark); } // --- Function to add a checkmark to the modal UI --- function updateViewWithCheckmark(barcode) { const modal = document.getElementById('barcode-viewer-modal'); if (!modal) return; // Don't do anything if the modal isn't open const row = modal.querySelector(`tr[data-barcode-row="${barcode}"]`); if (row) { const statusCell = row.querySelector('.status-cell-bc'); if (statusCell) { statusCell.innerHTML = `<span style="color: #4CAF50; font-weight: bold; font-size: 1.2em;" title="This barcode has been found on a patient page.">✔</span>`; } } } // --- Robust method to find the correct filter input --- function findFloatingFilterInputByHeader(headerText) { const headerViewport = document.querySelector('.ag-header-viewport'); if (!headerViewport) { console.error("[Barcode Collector] Could not find the ag-grid header viewport."); return null; } const allTitleCells = Array.from(headerViewport.querySelectorAll('.ag-header-row[aria-rowindex="1"] .ag-header-cell')); if (allTitleCells.length === 0) { console.error("[Barcode Collector] Could not find any header cells in the title row."); return null; } 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) { console.error(`[Barcode Collector] Could not find header cell with text: "${headerText}"`); return null; } const filterRow = headerViewport.querySelector('.ag-header-row[aria-rowindex="2"]'); if (!filterRow) { console.error(`[Barcode Collector] Could not find the filter row.`); return null; } const filterCell = filterRow.children[targetColumnIndex]; if (!filterCell) { console.error(`[Barcode Collector] Could not find filter cell at index: ${targetColumnIndex}`); return null; } return filterCell.querySelector('input.ag-floating-filter-input'); } // --- REVISED: Function to enter a barcode into the ag-grid filter --- function enterBarcodeInFilter(barcode) { const targetInput = findFloatingFilterInputByHeader('Barcode'); if (targetInput) { // --- More Robust Event Simulation for Angular/ag-grid --- targetInput.focus(); targetInput.value = barcode; // Dispatch events in a sequence that frameworks usually pick up on targetInput.dispatchEvent(new Event('input', { bubbles: true, cancelable: true })); targetInput.dispatchEvent(new Event('change', { bubbles: true, cancelable: true })); targetInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true })); targetInput.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true })); targetInput.blur(); // Trigger change detection console.log(`Barcode Collector: Programmatically entered barcode "${barcode}" into the filter.`); } else { console.error('Barcode Collector: Could not find the "Barcode" filter input field on the page.'); } } // Function to display the modal with collected barcodes async function showBarcodesModal() { const existingModal = document.getElementById('barcode-viewer-modal'); if (existingModal) existingModal.remove(); const barcodes = await GM_getValue(STORAGE_KEY, []); // Sort by count ascending, so the earliest collected are first barcodes.sort((a, b) => a.count - b.count); const modal = document.createElement('div'); modal.id = 'barcode-viewer-modal'; let tableRows = barcodes.map(entry => ` <tr data-barcode-row="${entry.barcode}"> <td>${entry.count}</td> <td>${entry.barcode}</td> <td>${new Date(entry.timestamp).toLocaleString()}</td> <td data-timestamp="${entry.timestamp}">${formatTimeSince(entry.timestamp)}</td> <td class="status-cell-bc"> ${entry.found ? `<span style="color: #4CAF50; font-weight: bold; font-size: 1.2em;" title="This barcode has been found on a patient page.">✔</span>` : ''} </td> <td class="action-cell-bc"><span class="delete-barcode-btn" data-barcode="${entry.barcode}" title="Delete this barcode">×</span></td> </tr> `).join(''); if (barcodes.length === 0) { tableRows = '<tr><td colspan="6">No barcodes have been collected yet.</td></tr>'; } modal.innerHTML = ` <div class="modal-content-bc"> <div class="modal-header-bc"> <h2>Pending Barcodes</h2> <span class="close-button-bc">×</span> </div> <div class="modal-body-bc"> <table> <thead> <tr> <th>Count</th> <th>Barcode</th> <th>Timestamp</th> <th>Time Since Added</th> <th>Status</th> <th>Actions</th> </tr> </thead> <tbody>${tableRows}</tbody> </table> </div> <div class="modal-footer-bc"> <button id="clear-barcodes-btn">Clear All Barcodes</button> </div> </div> `; document.body.appendChild(modal); // --- Immediately check for a barcode on the current page when modal opens --- if (lastCheckedPatientBarcode) { updateViewWithCheckmark(lastCheckedPatientBarcode); } // Auto-update the "Time Since Added" column every 5 seconds const timeSinceInterval = setInterval(() => { modal.querySelectorAll('td[data-timestamp]').forEach(cell => { cell.textContent = formatTimeSince(cell.dataset.timestamp); }); }, 5000); // --- Event Listener for Clicks Inside the Table Body --- const tableBody = modal.querySelector('tbody'); tableBody.addEventListener('click', async function(event) { const row = event.target.closest('tr'); // Do nothing if the click is not on a row with a barcode if (!row || !row.dataset.barcodeRow) { return; } // Check if the delete button was clicked if (event.target.classList.contains('delete-barcode-btn')) { const barcodeToDelete = event.target.dataset.barcode; await deleteBarcode(barcodeToDelete); // Refresh the modal content without closing it const updatedBarcodes = await GM_getValue(STORAGE_KEY, []); updatedBarcodes.sort((a, b) => a.count - b.count); // Re-sort let newTableRows = updatedBarcodes.map(entry => ` <tr data-barcode-row="${entry.barcode}"> <td>${entry.count}</td> <td>${entry.barcode}</td> <td>${new Date(entry.timestamp).toLocaleString()}</td> <td data-timestamp="${entry.timestamp}">${formatTimeSince(entry.timestamp)}</td> <td class="status-cell-bc"> ${entry.found ? `<span style="color: #4CAF50; font-weight: bold; font-size: 1.2em;" title="This barcode has been found on a patient page.">✔</span>` : ''} </td> <td class="action-cell-bc"><span class="delete-barcode-btn" data-barcode="${entry.barcode}" title="Delete this barcode">×</span></td> </tr> `).join(''); if (updatedBarcodes.length === 0) { newTableRows = '<tr><td colspan="6">No barcodes have been collected yet.</td></tr>'; } tableBody.innerHTML = newTableRows; // After rebuilding the table, re-apply the checkmark if necessary if (lastCheckedPatientBarcode) { updateViewWithCheckmark(lastCheckedPatientBarcode); } } // Otherwise, it was a row click to enter the barcode else { const barcodeToEnter = row.dataset.barcodeRow; enterBarcodeInFilter(barcodeToEnter); // Stop the auto-update timer and close the modal clearInterval(timeSinceInterval); modal.remove(); } }); modal.querySelector('.close-button-bc').addEventListener('click', () => { clearInterval(timeSinceInterval); // Stop the auto-update modal.remove(); }); const clearButton = modal.querySelector('#clear-barcodes-btn'); clearButton.addEventListener('click', () => { clearInterval(timeSinceInterval); // Stop the auto-update showClearConfirmation(modal); }); } // --- Function to Delete a Single Barcode --- async function deleteBarcode(barcodeToDelete) { let barcodes = await GM_getValue(STORAGE_KEY, []); // Filter out the barcode to delete let updatedBarcodes = barcodes.filter(entry => entry.barcode !== barcodeToDelete); // Re-calculate the 'count' for the remaining items updatedBarcodes.forEach((entry, index) => { entry.count = index + 1; }); await GM_setValue(STORAGE_KEY, updatedBarcodes); console.log(`Barcode Collector: Deleted barcode - ${barcodeToDelete}`); } // Function to show a confirmation dialog inside the modal function showClearConfirmation(modal) { const modalBody = modal.querySelector('.modal-body-bc'); const modalFooter = modal.querySelector('.modal-footer-bc'); modalBody.innerHTML = ` <p class="confirm-text-bc"> Are you sure you want to delete all pending barcodes?<br>This action cannot be undone. </p>`; modalFooter.innerHTML = ` <button id="confirm-clear-btn" class="modal-btn-bc confirm-btn-bc">Yes, Clear All</button> <button id="cancel-clear-btn" class="modal-btn-bc cancel-btn-bc">Cancel</button>`; modal.querySelector('#confirm-clear-btn').addEventListener('click', async () => { await GM_setValue(STORAGE_KEY, []); modal.remove(); showBarcodesModal(); }); modal.querySelector('#cancel-clear-btn').addEventListener('click', () => { modal.remove(); showBarcodesModal(); }); } // --- Styling for the Modal --- GM_addStyle(` #barcode-viewer-modal { position: fixed; z-index: 10000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; font-family: Arial, sans-serif; } .modal-content-bc { background-color: #fefefe; border-radius: 8px; box-shadow: 0 4px 8px 0 rgba(0,0,0,0.2); width: 80%; max-width: 700px; max-height: 80vh; display: flex; flex-direction: column; } .modal-header-bc { padding: 10px 16px; background-color: #5c6bc0; color: white; border-top-left-radius: 8px; border-top-right-radius: 8px; display: flex; justify-content: space-between; align-items: center; } .modal-header-bc h2 { margin: 0; font-size: 1.25em; } .close-button-bc { color: #fff; font-size: 28px; font-weight: bold; cursor: pointer; } .close-button-bc:hover { color: #f1f1f1; } .modal-body-bc { padding: 16px; overflow-y: auto; } .modal-body-bc table { width: 100%; border-collapse: collapse; } .modal-body-bc th, .modal-body-bc td { border: 1px solid #ddd; padding: 8px; text-align: left; } .modal-body-bc th { background-color: #f2f2f2; } .modal-body-bc tbody tr { cursor: pointer; } .modal-body-bc tbody tr:hover { background-color: #e8eaf6; /* Light indigo for hover */ } .modal-footer-bc { padding: 10px 16px; text-align: right; border-top: 1px solid #ddd; display: flex; justify-content: flex-end; gap: 10px; } #clear-barcodes-btn { background-color: #ef5350; color: white; border: none; padding: 10px 15px; border-radius: 5px; cursor: pointer; font-weight: bold; } #clear-barcodes-btn:hover { background-color: #d32f2f; } .confirm-text-bc { text-align: center; font-size: 1.1em; margin: 20px; } .modal-btn-bc { border: none; padding: 10px 15px; border-radius: 5px; cursor: pointer; font-weight: bold; } .confirm-btn-bc { background-color: #ef5350; color: white; } .confirm-btn-bc:hover { background-color: #d32f2f; } .cancel-btn-bc { background-color: #e0e0e0; color: #333; } .cancel-btn-bc:hover { background-color: #bdbdbd; } .action-cell-bc, .status-cell-bc { text-align: center !important; } .delete-barcode-btn { cursor: pointer; font-weight: bold; font-size: 20px; color: #ef5350; padding: 0 5px; border-radius: 4px; } .delete-barcode-btn:hover { color: white; background-color: #d32f2f; } `); // --- Start the script --- initialize(); })();