Greasy Fork 支持简体中文。

AG-Grid Data Extractor - Complete (Modified)

Collects unique barcodes with clinic info, 50 rows per page, sorted by last modified date, truncates clinic names, improved SPA support - Buttons next to Reset

// ==UserScript==
// @name          AG-Grid Data Extractor - Complete (Modified)
// @namespace     http://tampermonkey.net/
// @version       2.5 // Increased version number
// @description   Collects unique barcodes with clinic info, 50 rows per page, sorted by last modified date, truncates clinic names, improved SPA support - Buttons next to Reset
// @author        Your Name
// @match         https://his.kaauh.org/lab/*
// @grant         GM_addStyle
// ==/UserScript==

(function() {
    'use strict';

    const dateColumnIds = ['orderCreatedOnEpoch', 'createdDate', 'orderDate'];
    const lastModifiedColumnId = 'orderLastModifiedOnEpoch';
    const clinicColumnId = 'clinic';
    const barcodeColumnId = 'barcode';
    const testDescriptionColumnId = 'testDescription';
    const agGridBodyViewportSelector = '.ag-body-viewport.ag-layout-normal';
    const agGridRowSelector = '.ag-row[role="row"]';
    const ROWS_PER_PAGE = 500;
    const CLINIC_TRUNCATE_LENGTH = 50;

    GM_addStyle(`
        .userscript-container {
            position: fixed;
            top: 20px;
            left: 20px;
            background: #fff;
            padding: 20px;
            border: 1px solid #bbb;
            border-radius: 8px;
            box-shadow: 0 4px 12px rgba(0,0,0,0.15);
            z-index: 9999;
            max-height: 80vh;
            overflow-y: auto;
            min-width: 400px;
            max-width: 95%;
            font-family: sans-serif;
        }
        .userscript-title {
            margin-top: 0;
            margin-bottom: 15px;
            color: #333;
            font-size: 1.4em;
            border-bottom: 1px solid #eee;
            padding-bottom: 10px;
        }
        .userscript-table {
            border-collapse: collapse;
            width: 100%;
            font-size: 12px;
        }
        .userscript-table th,
        .userscript-table td {
            border: 1px solid #ddd;
            padding: 4px 6px;
            text-align: left;
            vertical-align: top;
            word-break: break-word;
        }
         .userscript-table td {
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
         }
        .userscript-table th {
            background-color: #007bff;
            color: white;
            font-weight: bold;
            border-color: #007bff;
            font-size: 11px;
            padding: 6px;
        }
        .userscript-table tr:nth-child(even) {
            background-color: #f8f9fa;
        }
        .userscript-pagination {
            display: flex;
            justify-content: center;
            margin-top: 10px;
            gap: 5px;
        }
        .userscript-page-btn {
            padding: 2px 6px;
            font-size: 12px;
            cursor: pointer;
            border: 1px solid #ddd;
            background: white;
            border-radius: 3px;
        }
        .userscript-active-page {
            font-weight: bold;
            background: #007bff;
            color: white;
            border-color: #007bff;
        }
        .userscript-button-container {
            margin-top: 15px;
            display: flex;
            gap: 10px;
            flex-wrap: wrap;
        }
        .userscript-button {
            padding: 8px 16px;
            border-radius: 4px;
            border: none;
            cursor: pointer;
            font-weight: bold;
            transition: all 0.2s;
        }
        .userscript-button-primary {
            background-color: #007bff;
            color: white;
        }
        .userscript-button-primary:hover {
            background-color: #0069d9;
        }
        .userscript-button-danger {
            background-color: #dc3545;
            color: white;
        }
        .userscript-button-danger:hover {
            background-color: #c82333;
        }
        .userscript-scroll-message {
            position: fixed;
            bottom: 20px;
            left: 50%;
            transform: translateX(-50%);
            background-color: #e0f7fa;
            padding: 10px 15px;
            border: 1px solid #00bcd4;
            border-radius: 5px;
            z-index: 9998;
            font-size: 0.9em;
        }
    `);

    let collectedDataMap = new Map();
    let gridObserver = null;
    let observerForButtonContainer = null;
    let activeCreationDateColumn = null;
    let uiElements = {
        extractButton: null,
        stopButton: null,
        scrollMessage: null
    };

    function parseDateFromCell(text) {
        if (!text) return 0;
        if (/^\d{10,}$/.test(text)) return parseInt(text);
        const date = new Date(text);
        return isNaN(date) ? 0 : date.getTime();
    }

    function formatDate(timestamp) {
        return timestamp ? new Date(timestamp).toLocaleString() : 'N/A';
    }

    function escapeCsv(text) {
        const strText = String(text || '')
            .replace(/"/g, '""')
            .replace(/^[=+@-]/, "'$&");
        return `"${strText}"`;
    }

    function truncateText(text, length) {
        const strText = String(text || '');
        if (strText.length > length) {
            return strText.substring(0, length) + '...';
        }
        return strText;
    }

    function waitForElement(selector, options = {}) {
        const { timeout = 10000, pollInterval = 250 } = options;
        return new Promise((resolve, reject) => {
            const element = document.querySelector(selector);
            if (element) return resolve(element);

            const startTime = Date.now();
            const poll = setInterval(() => {
                const element = document.querySelector(selector);
                if (element) {
                    clearInterval(poll);
                    resolve(element);
                } else if (Date.now() - startTime >= timeout) {
                    clearInterval(poll);
                    resolve(null);
                }
            }, pollInterval);
        });
    }


    function waitForGridData() {
        return new Promise((resolve, reject) => {
            const startTime = Date.now();
            const poll = setInterval(() => {
                const rows = document.querySelectorAll(agGridRowSelector);
                if (rows.length > 0) {
                    clearInterval(poll);
                    setTimeout(resolve, 1000);
                } else if (Date.now() - startTime >= 30000) {
                    clearInterval(poll);
                    reject(new Error('No grid rows detected within timeout'));
                }
            }, 500);
        });
    }

    function showTemporaryMessage(message, type = 'info', duration = 5000) {
        const colors = {
            info: { bg: '#e0f7fa', border: '#00bcd4', text: '#006064' },
            success: { bg: '#d4edda', border: '#c3e6cb', text: '#155724' },
            error: { bg: '#f8d7da', border: '#f5c6cb', text: '#721c24' }
        };

        const messageDiv = document.createElement('div');
        messageDiv.style.cssText = `
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            padding: 15px 20px;
            border-radius: 5px;
            z-index: 10000;
            font-weight: bold;
            box-shadow: 0 2px 10px rgba(0,0,0,0.2);
            max-width: 80%;
            text-align: center;
            background-color: ${colors[type].bg};
            border: 1px solid ${colors[type].border};
            color: ${colors[type].text};
        `;
        messageDiv.textContent = message;

        document.body.appendChild(messageDiv);
        setTimeout(() => messageDiv.remove(), type === 'error' ? 10000 : duration);
    }

    function processRowElement(rowElement) {
        const rowData = {};
        const cells = rowElement.querySelectorAll('.ag-cell');

        if (!activeCreationDateColumn) {
            for (const colId of dateColumnIds) {
                if (rowElement.querySelector(`[col-id="${colId}"]`)) {
                    activeCreationDateColumn = colId;
                    console.log(`Detected active creation date column: ${activeCreationDateColumn}`);
                    break;
                }
            }
            if (!activeCreationDateColumn) {
                 console.warn('No known date column found in this row.');
            }
        }


        cells.forEach(cell => {
            const colId = cell.getAttribute('col-id') || cell.parentElement?.getAttribute('col-id');
            if (colId) rowData[colId] = cell.textContent.trim();
        });

        if (rowData[barcodeColumnId] && rowData[testDescriptionColumnId] && activeCreationDateColumn && rowData[lastModifiedColumnId]) {
            if (!collectedDataMap.has(rowData[barcodeColumnId])) {
                collectedDataMap.set(rowData[barcodeColumnId], {
                    testDescription: rowData[testDescriptionColumnId],
                    barcode: rowData[barcodeColumnId],
                    clinic: rowData[clinicColumnId] || 'N/A',
                    orderCreationDate: rowData[activeCreationDateColumn],
                    orderLastModified: rowData[lastModifiedColumnId],
                    creationTimestamp: parseDateFromCell(rowData[activeCreationDateColumn]),
                    modifiedTimestamp: parseDateFromCell(rowData[lastModifiedColumnId])
                });
            }
        } else {
             if (!rowData[barcodeColumnId]) console.warn('Skipping row due to missing barcode.');
             if (!rowData[testDescriptionColumnId]) console.warn('Skipping row due to missing test description.');
             if (!activeCreationDateColumn) console.warn('Skipping row due to unknown creation date column.');
             if (!rowData[lastModifiedColumnId]) console.warn('Skipping row due to missing last modified date.');
        }
    }

    function createPagination(totalPages, currentPage, onPageChange) {
        const container = document.createElement('div');
        container.className = 'userscript-pagination';

        if (currentPage > 1) {
            const prevBtn = document.createElement('button');
            prevBtn.className = 'userscript-page-btn';
            prevBtn.textContent = '←';
            prevBtn.onclick = () => onPageChange(currentPage - 1);
            container.appendChild(prevBtn);
        }

        const startPage = Math.max(1, currentPage - 2);
        const endPage = Math.min(totalPages, currentPage + 2);

        if (startPage > 1) {
            const firstBtn = document.createElement('button');
            firstBtn.className = 'userscript-page-btn';
            firstBtn.textContent = '1';
            firstBtn.onclick = () => onPageChange(1);
            container.appendChild(firstBtn);

            if (startPage > 2) {
                const ellipsis = document.createElement('span');
                ellipsis.textContent = '...';
                ellipsis.style.padding = '0 5px';
                container.appendChild(ellipsis);
            }
        }

        for (let i = startPage; i <= endPage; i++) {
            const btn = document.createElement('button');
            btn.className = `userscript-page-btn ${i === currentPage ? 'userscript-active-page' : ''}`;
            btn.textContent = i;
            btn.onclick = () => onPageChange(i);
            container.appendChild(btn);
        }

        if (endPage < totalPages) {
            if (endPage < totalPages - 1) {
                const ellipsis = document.createElement('span');
                ellipsis.textContent = '...';
                ellipsis.style.padding = '0 5px';
                container.appendChild(ellipsis);
            }

            const lastBtn = document.createElement('button');
            lastBtn.className = 'userscript-page-btn';
            lastBtn.textContent = totalPages;
            lastBtn.onclick = () => onPageChange(totalPages);
            container.appendChild(lastBtn);
        }

        if (currentPage < totalPages) {
            const nextBtn = document.createElement('button');
            nextBtn.className = 'userscript-page-btn';
            nextBtn.textContent = '→';
            nextBtn.onclick = () => onPageChange(currentPage + 1);
            container.appendChild(nextBtn);
        }

        return container;
    }

    function displayData(data) {
        document.querySelectorAll('.userscript-container').forEach(el => el.remove());

        if (data.length === 0) {
            showTemporaryMessage('No data collected matching the criteria', 'error');
            return;
        }

        const sortedData = [...data].sort((a, b) => b.modifiedTimestamp - a.modifiedTimestamp);
        const totalPages = Math.ceil(sortedData.length / ROWS_PER_PAGE);
        let currentPage = 1;

        const container = document.createElement('div');
        container.className = 'userscript-container';

        const title = document.createElement('h2');
        title.className = 'userscript-title';
        title.textContent = `PENDING TESTS (${sortedData.length} records)`;
        container.appendChild(title);

        const renderPage = (page) => {
            const startIdx = (page - 1) * ROWS_PER_PAGE;
            const endIdx = startIdx + ROWS_PER_PAGE;
            const pageData = sortedData.slice(startIdx, endIdx);

            const oldTable = container.querySelector('.userscript-table-container');
            if (oldTable) oldTable.remove();

            const tableContainer = document.createElement('div');
            tableContainer.className = 'userscript-table-container';

            const table = document.createElement('table');
            table.className = 'userscript-table';
            table.setAttribute('role', 'grid');

            const headers = ['NO.', 'Test Description', 'Barcode', 'Clinic', 'Created', 'Last Modified'];
            const thead = document.createElement('thead');
            const headerRow = document.createElement('tr');

            headers.forEach((text, i) => {
                const th = document.createElement('th');
                th.textContent = text;
                headerRow.appendChild(th);
            });

            thead.appendChild(headerRow);
            table.appendChild(thead);

            const tbody = document.createElement('tbody');
            pageData.forEach((item, idx) => {
                const row = document.createElement('tr');
                const absoluteIdx = startIdx + idx + 1;

                [
                    absoluteIdx,
                    item.testDescription,
                    item.barcode,
                    truncateText(item.clinic, CLINIC_TRUNCATE_LENGTH),
                    formatDate(item.creationTimestamp),
                    formatDate(item.modifiedTimestamp)
                ].forEach(content => {
                    const td = document.createElement('td');
                    td.textContent = content;
                    row.appendChild(td);
                });

                tbody.appendChild(row);
            });
            table.appendChild(tbody);
            tableContainer.appendChild(table);

            const oldPagination = container.querySelector('.userscript-pagination');
            if (oldPagination) oldPagination.remove();
            tableContainer.appendChild(createPagination(totalPages, page, (newPage) => {
                currentPage = newPage;
                renderPage(newPage);
            }));

            const buttonContainerElement = container.querySelector('.userscript-button-container');
            if (buttonContainerElement) {
                container.insertBefore(tableContainer, buttonContainerElement);
            } else {
                container.appendChild(tableContainer);
            }
        };

        let buttonContainer = container.querySelector('.userscript-button-container');
        if (!buttonContainer) {
            buttonContainer = document.createElement('div');
            buttonContainer.className = 'userscript-button-container';
            container.appendChild(buttonContainer);
        }

        const printBtn = document.createElement('button');
        printBtn.className = 'userscript-button userscript-button-primary';
        printBtn.textContent = 'Print All';
        printBtn.onclick = () => {
            const printWindow = window.open('', '_blank');
            printWindow.document.write(`
                <html>
                    <head>
                        <title>Test Descriptions and Barcodes</title>
                        <style>
                            body { font-family: sans-serif; margin: 10mm; }
                            h2 { margin-top: 0; }
                            table { border-collapse: collapse; width: 100%; font-size: 10px; margin-bottom: 20px; }
                            th, td { border: 1px solid #ddd; padding: 3px 5px; }
                            th { background-color: #f2f2f2; font-weight: bold; }
                            .page-break { page-break-after: always; }
                            @page { size: auto; margin: 10mm; }
                             td {
                                white-space: nowrap;
                                overflow: hidden;
                                text-overflow: ellipsis;
                            }
                        </style>
                    </head>
                    <body>
                        <h2>PENDING TESTS (${sortedData.length} records)</h2>
            `);

            for (let i = 0; i < sortedData.length; i += ROWS_PER_PAGE) {
                const pageData = sortedData.slice(i, i + ROWS_PER_PAGE);

                printWindow.document.write(`
                    <table>
                        <thead>
                            <tr>
                                <th>NO.</th>
                                <th>Test Description</th>
                                <th>Barcode</th>
                                <th>Clinic</th>
                                <th>Created</th>
                                <th>Last Modified</th>
                            </tr>
                        </thead>
                        <tbody>
                `);

                pageData.forEach((item, idx) => {
                    printWindow.document.write(`
                        <tr>
                            <td>${i + idx + 1}</td>
                            <td>${item.testDescription}</td>
                            <td>${item.barcode}</td>
                            <td>${truncateText(item.clinic, CLINIC_TRUNCATE_LENGTH)}</td>
                            <td>${formatDate(item.creationTimestamp)}</td>
                            <td>${formatDate(item.modifiedTimestamp)}</td>
                        </tr>
                    `);
                });

                printWindow.document.write('</tbody></table>');

                if (i + ROWS_PER_PAGE < sortedData.length) {
                    printWindow.document.write('<div class="page-break"></div>');
                }
            }

            printWindow.document.write(`
                        <script>
                            setTimeout(function() {
                                window.print();
                                window.close();
                            }, 200);
                        </script>
                    </body>
                </html>
            `);
            printWindow.document.close();
        };
        buttonContainer.appendChild(printBtn);

        const downloadBtn = document.createElement('button');
        downloadBtn.className = 'userscript-button userscript-button-primary';
        downloadBtn.textContent = 'Download CSV';
        downloadBtn.onclick = () => {
            const headers = ['NO.', 'Test Description', 'Barcode', 'Clinic', 'Created', 'Last Modified'];
            let csv = headers.map(escapeCsv).join(',') + '\n';

            sortedData.forEach((item, index) => {
                csv += [
                    index + 1,
                    item.testDescription,
                    item.barcode,
                    item.clinic,
                    formatDate(item.creationTimestamp),
                    formatDate(item.modifiedTimestamp)
                ].map(escapeCsv).join(',') + '\n';
            });

            const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
            const url = URL.createObjectURL(blob);
            const link = document.createElement('a');
            link.href = url;
            link.download = `test_data_${new Date().toISOString().slice(0,10)}.csv`;

            document.body.appendChild(link);
            link.click();
            document.body.removeChild(link);

            setTimeout(() => URL.revokeObjectURL(url), 100);
        };
        buttonContainer.appendChild(downloadBtn);

        const closeBtn = document.createElement('button');
        closeBtn.className = 'userscript-button userscript-button-danger';
        closeBtn.textContent = 'Close';
        closeBtn.onclick = () => container.remove();
        buttonContainer.appendChild(closeBtn);

        document.body.appendChild(container);

        renderPage(1);
    }

    async function startExtraction() {
        if (gridObserver) {
            showTemporaryMessage('Extraction already in progress', 'info');
            return;
        }

        collectedDataMap.clear();
        activeCreationDateColumn = null;
        if (uiElements.extractButton) uiElements.extractButton.style.display = 'none';
        if (uiElements.stopButton) uiElements.stopButton.style.display = 'inline-block';


        showTemporaryMessage('Preparing extraction...', 'info');

        try {
            const gridBodyViewport = await waitForElement(agGridBodyViewportSelector, { timeout: 30000 });
            if (!gridBodyViewport) {
                 throw new Error('AG-Grid viewport not found within timeout.');
            }

            await waitForGridData();

            gridBodyViewport.querySelectorAll(agGridRowSelector).forEach(row => {
                row.dataset.processed = 'true';
                processRowElement(row);
            });

            if (!uiElements.scrollMessage) {
                uiElements.scrollMessage = document.createElement('div');
                uiElements.scrollMessage.className = 'userscript-scroll-message';
                document.body.appendChild(uiElements.scrollMessage);
            }
            uiElements.scrollMessage.textContent = `Collected ${collectedDataMap.size} records. Scroll to load more...`;
            uiElements.scrollMessage.style.display = 'block';

            gridObserver = new MutationObserver(() => {
                clearTimeout(gridObserver._debounce);
                gridObserver._debounce = setTimeout(() => {
                    const newRows = Array.from(gridBodyViewport.querySelectorAll(agGridRowSelector))
                        .filter(row => !row.dataset.processed);

                    newRows.forEach(row => {
                        row.dataset.processed = 'true';
                        processRowElement(row);
                    });

                    if (uiElements.scrollMessage) {
                        uiElements.scrollMessage.textContent = `Collected ${collectedDataMap.size} records`;
                    }
                     console.log(`Processed ${newRows.length} new rows. Total collected: ${collectedDataMap.size}`);
                }, 150);
            });

            gridObserver.observe(gridBodyViewport, { childList: true, subtree: true });

            showTemporaryMessage('Extraction started. Scroll down the grid to collect more data.', 'success');

        } catch (error) {
            console.error('Extraction error:', error);
            cleanup();
            showTemporaryMessage(
                error.message.includes('Timeout') ?
                    'Grid loading timeout or no rows found.' :
                    'Failed to start extraction.',
                'error',
                10000
            );
        }
    }

    function stopExtractionAndDisplay() {
        if (!gridObserver) {
            showTemporaryMessage('No active extraction to stop', 'info');
            return;
        }

        cleanup();
        const finalData = Array.from(collectedDataMap.values());

        if (finalData.length === 0) {
            showTemporaryMessage(
                'No data collected. Possible issues:\n1. No matching columns found.\n2. Grid structure changed.\n3. No rows were loaded during extraction.',
                'error',
                10000
            );
            return;
        }

        showTemporaryMessage(`Collected ${finalData.length} records. Generating report...`, 'success', 3000);
        setTimeout(() => displayData(finalData), 500);
    }

    function addButtonsToPage() {
        if (uiElements.extractButton && document.body.contains(uiElements.extractButton)) {
            return;
        }

        const resetButton = document.querySelector('.nova-btn.nova-btn--ghost.nova-btn--md[translateid="lab-order-list.Reset"]');

        if (resetButton && resetButton.parentElement) {
            uiElements.extractButton = document.createElement('button');
            uiElements.extractButton.textContent = 'PRINT';
            uiElements.extractButton.className = 'userscript-button nova-btn nova-btn--primary nova-btn--md';
            uiElements.extractButton.style.marginLeft = '10px';
            uiElements.extractButton.onclick = startExtraction;

            uiElements.stopButton = document.createElement('button');
            uiElements.stopButton.textContent = 'Stop & Generate Report';
            uiElements.stopButton.className = 'userscript-button nova-btn nova-btn--danger nova-btn--md';
            uiElements.stopButton.style.marginLeft = '10px';
            uiElements.stopButton.style.display = 'none';
            uiElements.stopButton.onclick = stopExtractionAndDisplay;

            resetButton.parentElement.insertBefore(uiElements.extractButton, resetButton.nextSibling);
            uiElements.extractButton.parentElement.insertBefore(uiElements.stopButton, uiElements.extractButton.nextSibling);

            console.log('AG-Grid Extractor buttons added next to Reset button.');

        } else {
            console.log('Reset button not found, skipping adding extractor buttons.');
        }
    }


    function cleanup() {
        if (observerForButtonContainer) {
            observerForButtonContainer.disconnect();
            observerForButtonContainer = null;
            console.log('AG-Grid Extractor button container observer disconnected.');
        }

        if (gridObserver) {
            gridObserver.disconnect();
            clearTimeout(gridObserver._debounce);
            gridObserver = null;
            console.log('AG-Grid data observer disconnected.');
        }

        if (uiElements.extractButton && uiElements.extractButton.parentElement) {
            uiElements.extractButton.parentElement.removeChild(uiElements.extractButton);
            uiElements.extractButton = null;
        }
         if (uiElements.stopButton && uiElements.stopButton.parentElement) {
            uiElements.stopButton.parentElement.removeChild(uiElements.stopButton);
             uiElements.stopButton = null;
         }
        if (uiElements.scrollMessage && uiElements.scrollMessage.parentElement) {
             uiElements.scrollMessage.parentElement.removeChild(uiElements.scrollMessage);
             uiElements.scrollMessage = null;
        }
        console.log('AG-Grid Extractor UI elements removed.');
    }


    function initialize() {
        console.log('AG-Grid Data Extractor script initializing...');

        addButtonsToPage();

        const targetNode = document.body;
        const config = { childList: true, subtree: true };

        observerForButtonContainer = new MutationObserver((mutationsList, observer) => {
            addButtonsToPage();
        });

        observerForButtonContainer.observe(targetNode, config);
        console.log('MutationObserver started to watch for button container.');


        window.addEventListener('beforeunload', cleanup);
    }

     if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', initialize);
     } else {
        initialize();
     }

})();