自助分析下载当前活动步骤-CSV流传输保存

在10.177.71.114网站添加悬浮按钮下载当前活动步骤(并发分页 + 单 CSV 流式写入 + 可折叠日志 + 进度 + 16位以上大数保持精度)

// ==UserScript==
// @name         自助分析下载当前活动步骤-CSV流传输保存
// @namespace    http://tampermonkey.net/
// @version      2.0
// @description  在10.177.71.114网站添加悬浮按钮下载当前活动步骤(并发分页 + 单 CSV 流式写入 + 可折叠日志 + 进度 + 16位以上大数保持精度)
// @author       eden
// @match        https://10.177.71.114/*
// @grant        none
// @run-at       document-end
// @license      GPL-3.0
// ==/UserScript==

(function() {
    'use strict';

    // ---------- 配置参数 ----------
    const STORAGE_KEY = 'download_step_button_visible';
    const CONCURRENCY = 5;       // 并发请求数量
    const BATCH_SIZE = 10000;    // 后端每页大小(保持原接口)
    const CSV_CHUNK_ROWS = 2000; // 每次转换并 push 到 csvChunks 的行数(降低内存峰值)

    // ---------- UI / 按钮(保持原有样式/行为) ----------
    function createFloatingButton() {
        if (document.getElementById('download-step-container')) return;
        const container = document.createElement('div');
        container.id = 'download-step-container';
        container.style.position = 'fixed';
        container.style.left = '20px';
        container.style.top = '50%';
        container.style.transform = 'translateY(-50%)';
        container.style.zIndex = '99999';
        container.style.display = 'flex';
        container.style.flexDirection = 'column';
        container.style.gap = '10px';
        container.style.backgroundColor = 'rgba(255,255,255,0.9)';
        container.style.padding = '10px';
        container.style.borderRadius = '8px';
        container.style.boxShadow = '0 0 10px rgba(0,0,0,0.4)';
        document.body.appendChild(container);

        const button = document.createElement('button');
        button.id = 'download-step-button';
        button.textContent = '下载当前步骤';
        button.style.padding = '10px 16px';
        button.style.backgroundColor = '#4CAF50';
        button.style.color = '#fff';
        button.style.border = 'none';
        button.style.borderRadius = '6px';
        button.style.cursor = 'pointer';
        button.style.fontWeight = '600';
        button.addEventListener('click', () => downloadActiveStep());

        const toggleButton = document.createElement('button');
        toggleButton.id = 'toggle-button';
        toggleButton.textContent = '隐藏';
        toggleButton.style.padding = '8px 12px';
        toggleButton.style.backgroundColor = '#2196F3';
        toggleButton.style.color = '#fff';
        toggleButton.style.border = 'none';
        toggleButton.style.borderRadius = '6px';
        toggleButton.style.cursor = 'pointer';
        toggleButton.addEventListener('click', () => {
            const btn = document.getElementById('download-step-button');
            if (!btn) return;
            if (btn.style.display === 'none') {
                btn.style.display = 'block';
                toggleButton.textContent = '隐藏';
                localStorage.setItem(STORAGE_KEY, 'true');
            } else {
                btn.style.display = 'none';
                toggleButton.textContent = '显示';
                localStorage.setItem(STORAGE_KEY, 'false');
            }
        });

        const isVisible = localStorage.getItem(STORAGE_KEY) !== 'false';
        button.style.display = isVisible ? 'block' : 'none';
        toggleButton.textContent = isVisible ? '隐藏' : '显示';

        container.appendChild(button);
        container.appendChild(toggleButton);
    }

    // ---------- Cookie / Headers ----------
    function getCookieValue(name) {
        const cookies = document.cookie.split(';');
        for (let cookie of cookies) {
            const [k, v] = cookie.trim().split('=');
            if (k === name) return v;
        }
        return '';
    }
    function getCurrentHeaders() {
        const token = getCookieValue('token') || '';
        const guid = getCookieValue('guid') || '';
        const headers = {
            "User-Agent": navigator.userAgent,
            "Accept": "*/*",
            "Accept-Language": navigator.language || "zh-CN,zh;q=0.8",
            "Sec-Fetch-Dest": "empty",
            "Sec-Fetch-Mode": "cors",
            "Sec-Fetch-Site": "same-origin"
        };
        if (token) headers.token = token;
        if (guid) headers.guid = guid;
        return headers;
    }

    // ---------- 日志面板 + 进度条(可折叠) ----------
    function ensureLogWindow() {
        if (document.getElementById('log-window')) return;
        const logWindow = document.createElement('div');
        logWindow.id = 'log-window';
        logWindow.style.position = 'fixed';
        logWindow.style.bottom = '12px';
        logWindow.style.right = '12px';
        logWindow.style.width = '480px';
        logWindow.style.maxHeight = '460px';
        logWindow.style.background = 'rgba(0,0,0,0.85)';
        logWindow.style.color = '#bfffbf';
        logWindow.style.fontFamily = 'monospace';
        logWindow.style.fontSize = '12px';
        logWindow.style.padding = '8px';
        logWindow.style.borderRadius = '6px';
        logWindow.style.zIndex = '10000';
        logWindow.style.display = 'flex';
        logWindow.style.flexDirection = 'column';
        logWindow.style.boxShadow = '0 0 12px rgba(0,0,0,0.6)';

        // progress elements
        const progressWrapper = document.createElement('div');
        progressWrapper.style.width = '100%';
        progressWrapper.style.height = '12px';
        progressWrapper.style.background = '#222';
        progressWrapper.style.border = '1px solid #444';
        progressWrapper.style.borderRadius = '6px';
        progressWrapper.style.overflow = 'hidden';
        progressWrapper.style.marginBottom = '6px';
        const progressInner = document.createElement('div');
        progressInner.id = 'log-progress-inner';
        progressInner.style.width = '0%';
        progressInner.style.height = '100%';
        progressInner.style.background = '#4caf50';
        progressInner.style.transition = 'width 200ms linear';
        progressWrapper.appendChild(progressInner);

        const progressText = document.createElement('div');
        progressText.id = 'log-progress-text';
        progressText.style.margin = '6px 0';
        progressText.style.fontSize = '11px';
        progressText.textContent = '进度:0%';

        const titleBar = document.createElement('div');
        titleBar.style.display = 'flex';
        titleBar.style.justifyContent = 'space-between';
        titleBar.style.alignItems = 'center';
        titleBar.style.marginBottom = '6px';
        const title = document.createElement('div');
        title.textContent = '数据处理日志';
        title.style.fontWeight = '700';
        title.style.color = '#dfffd8';

        const btns = document.createElement('div');
        const collapseBtn = document.createElement('button');
        collapseBtn.textContent = '折叠';
        collapseBtn.style.marginRight = '8px';
        collapseBtn.style.cursor = 'pointer';
        const closeBtn = document.createElement('button');
        closeBtn.textContent = '×';
        closeBtn.style.cursor = 'pointer';
        btns.appendChild(collapseBtn);
        btns.appendChild(closeBtn);
        titleBar.appendChild(title);
        titleBar.appendChild(btns);

        const logContent = document.createElement('div');
        logContent.id = 'log-content';
        logContent.style.overflowY = 'auto';
        logContent.style.maxHeight = '320px';
        logContent.style.paddingRight = '6px';

        logWindow.appendChild(progressWrapper);
        logWindow.appendChild(progressText);
        logWindow.appendChild(titleBar);
        logWindow.appendChild(logContent);
        document.body.appendChild(logWindow);

        let collapsed = false;
        collapseBtn.addEventListener('click', function() {
            collapsed = !collapsed;
            if (collapsed) {
                progressWrapper.style.display = 'none';
                progressText.style.display = 'none';
                logContent.style.display = 'none';
                collapseBtn.textContent = '展开';
            } else {
                progressWrapper.style.display = 'block';
                progressText.style.display = 'block';
                logContent.style.display = 'block';
                collapseBtn.textContent = '折叠';
            }
        });
        closeBtn.addEventListener('click', function() { logWindow.style.display = 'none'; });

        // override console.log to also write to logContent
        const originalLog = console.log.bind(console);
        console.log = function(...args) {
            originalLog(...args);
            try {
                const message = args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ');
                const entry = document.createElement('div');
                entry.style.borderBottom = '1px dotted #333';
                entry.style.padding = '3px 0';
                entry.textContent = message;
                const lc = document.getElementById('log-content');
                if (lc) {
                    lc.appendChild(entry);
                    lc.scrollTop = lc.scrollHeight;
                }
            } catch (e) {
                originalLog('日志写入失败', e);
            }
        };

        window.__updateDownloadProgress = function(percentage, text) {
            const inner = document.getElementById('log-progress-inner');
            const t = document.getElementById('log-progress-text');
            if (inner) inner.style.width = `${Math.max(0, Math.min(100, percentage))}%`;
            if (t) t.textContent = (text ? text + ' ' : '') + `进度:${Math.round(percentage)}%`;
        };
    }

    // ---------- 辅助:安全 CSV 单元格生成(处理长数字/日期/转义) ----------
    function safeCSVCell(value, fieldDef) {
        if (value === null || value === undefined) return '';

        if (fieldDef && fieldDef.originalType === 'Date') {
            const d = parsePossibleDate(value);
            if (d instanceof Date && !isNaN(d.getTime())) {
                const s = formatDateTime(d);
                return `"${s.replace(/"/g, '""')}"`;
            } else {
                return '';
            }
        }

        const str = (typeof value === 'number') ? String(value) : String(value);

        if (/^\d{16,}$/.test(str)) {
            const inner = `="${str}"`.replace(/"/g, '""');
            return `"${inner}"`;
        }

        if (/[,"\r\n]/.test(str)) {
            return `"${str.replace(/"/g, '""')}"`;
        }
        return str;
    }

    function formatDateTime(d) {
        const pad = (n) => String(n).padStart(2, '0');
        return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
    }

    function parsePossibleDate(value) {
        if (value === null || value === undefined || value === '') return null;
        if (value instanceof Date) return value;
        if (typeof value === 'Number' || typeof value === 'number') {
            if (value > 1e12) return new Date(value);
            if (value > 1e9) return new Date(value * 1000);
            return new Date(value);
        }
        if (typeof value === 'string') {
            const s = value.trim();
            if (/^\d+$/.test(s)) {
                const n = parseInt(s, 10);
                if (n > 1e12) return new Date(n);
                if (n > 1e9) return new Date(n * 1000);
                return new Date(n);
            }
            const t = Date.parse(s);
            if (!isNaN(t)) return new Date(t);
            const m = s.match(/\/Date\((\d+)(?:[+-]\d+)?\)\//);
            if (m && m[1]) {
                const n = parseInt(m[1], 10);
                if (n > 1e12) return new Date(n);
                if (n > 1e9) return new Date(n * 1000);
                return new Date(n);
            }
        }
        return null;
    }

    // ---------- 将 rows (array of arrays) 转为 CSV 文本的一部分(按 fieldDefs) ----------
    function rowsToCsvChunk(rows, fieldDefs) {
        const lines = [];
        for (let r = 0; r < rows.length; r++) {
            const row = rows[r];
            const cells = [];
            for (let c = 0; c < row.length; c++) {
                const fieldDef = (fieldDefs && fieldDefs[c]) ? fieldDefs[c] : null;
                cells.push(safeCSVCell(row[c], fieldDef));
            }
            lines.push(cells.join(','));
        }
        return lines.join('\r\n') + '\r\n';
    }

    async function pushRowsToCsvChunks(rows, fieldDefs, csvChunks) {
        if (!rows || rows.length === 0) return 0;
        let written = 0;
        for (let i = 0; i < rows.length; i += CSV_CHUNK_ROWS) {
            const slice = rows.slice(i, i + CSV_CHUNK_ROWS);
            const chunkText = rowsToCsvChunk(slice, fieldDefs);
            csvChunks.push(chunkText);
            written += slice.length;
            await new Promise(resolve => setTimeout(resolve, 0));
        }
        return written;
    }

    function saveCsvChunksAsFile(csvChunks, fileName) {
        const parts = ['\uFEFF', ...csvChunks];
        const blob = new Blob(parts, { type: 'text/csv;charset=utf-8;' });
        const link = document.createElement('a');
        link.href = URL.createObjectURL(blob);
        link.download = fileName.endsWith('.csv') ? fileName : (fileName + '.csv');
        document.body.appendChild(link);
        link.click();
        setTimeout(() => {
            document.body.removeChild(link);
            URL.revokeObjectURL(link.href);
        }, 200);
    }

    function convertBatchToRows(batchArray) {
        const rows = [];
        if (!Array.isArray(batchArray)) return rows;
        if (batchArray.length === 0) return rows;
        if (Array.isArray(batchArray[0])) {
            for (let row of batchArray) {
                const processed = row.map(cell => (cell === null || cell === undefined ? '' : cell));
                rows.push(processed);
            }
        } else {
            for (let item of batchArray) {
                if (typeof item === 'object' && item !== null) {
                    const vals = Object.values(item);
                    const processed = vals.map(cell => (cell === null || cell === undefined ? '' : cell));
                    rows.push(processed);
                } else {
                    rows.push([item]);
                }
            }
        }
        return rows;
    }

    // ---------- 主逻辑:并发分页请求并流式写 CSV ----------
    async function downloadActiveStep() {
        try {
            console.log('开始下载当前步骤(CSV 流式导出)...');
            ensureLogWindow();

            const stepList = document.getElementById('steplist');
            if (!stepList) {
                alert('未找到步骤列表元素');
                return;
            }
            const activeLi = stepList.querySelector('li.active');
            if (!activeLi) {
                alert('未找到活动步骤元素');
                return;
            }
            const stepId = activeLi.id;
            if (!stepId) {
                alert('活动步骤没有ID');
                return;
            }

            if (!confirm('请确定已根据用户标识user_id/用户号码device_number排序(否则下载数据会错误重复),数据量过大可能导致浏览器卡死(不负责)')) {
                console.log('用户取消下载');
                return;
            }

            let headers = {};
            try { headers = getCurrentHeaders(); } catch (e) { headers = {}; }

            const countUrl = `https://10.177.71.114/datasience/xquery/getStepMessTotalCount?stepId=${stepId}`;
            console.log('获取数据总量:', countUrl);
            const countResp = await fetch(countUrl, { credentials: 'include', headers, referrer: window.location.href, method: 'GET', mode: 'cors' });
            const countData = await countResp.json();
            if (!countData || !countData.data || !countData.data.totalCount) {
                alert('获取数据总量失败或返回格式不正确');
                return;
            }
            const totalCount = parseInt(countData.data.totalCount);
            console.log('总数据量:', totalCount);
            const batchCount = Math.ceil(totalCount / BATCH_SIZE);
            console.log(`将分 ${batchCount} 批次请求(每批 ${BATCH_SIZE} 行)`);

            const csvChunks = [];
            let tableHeaders = [];
            let orgFieldDefsTemplate = null;
            let totalWritten = 0;

            const fetchConfig = { credentials: 'include', headers, referrer: window.location.href, method: 'GET', mode: 'cors' };

            for (let start = 0; start < batchCount; start += CONCURRENCY) {
                const groupPromises = [];
                const groupIdx = [];
                for (let j = 0; j < CONCURRENCY && (start + j) < batchCount; j++) {
                    const idx = start + j;
                    const offset = idx + 1;
                    const url = `https://10.177.71.114/datasience/xquery/getStepMassNew?stepId=${stepId}&limit=${BATCH_SIZE}&offset=${offset}`;
                    console.log(`请求第 ${idx + 1}/${batchCount} 批,offset=${offset}`);
                    const p = fetch(url, fetchConfig).then(r => r.json()).catch(e => ({ __fetchError: true, error: e }));
                    groupPromises.push(p);
                    groupIdx.push(idx);
                }

                const settled = await Promise.allSettled(groupPromises);

                for (let k = 0; k < settled.length; k++) {
                    const res = settled[k];
                    const batchIndex = groupIdx[k];
                    if (res.status === 'fulfilled') {
                        const batchData = res.value;
                        if (!batchData || !batchData.data || !Array.isArray(batchData.data.data)) {
                            console.error(`第 ${batchIndex + 1} 批数据异常`, batchData);
                            continue;
                        }

                        if (tableHeaders.length === 0 && batchData.data.orgFieldDefs && Array.isArray(batchData.data.orgFieldDefs) && batchData.data.orgFieldDefs.length > 0) {
                            const firstArray = batchData.data.orgFieldDefs[0];
                            if (Array.isArray(firstArray)) {
                                tableHeaders = firstArray.map(f => f.label || '');
                                orgFieldDefsTemplate = firstArray.map(f => ({ originalType: f.originalType, fieldName: f.fieldName }));
                            }
                        }

                        if (!orgFieldDefsTemplate && batchData.data.orgFieldDefs && Array.isArray(batchData.data.orgFieldDefs) && batchData.data.orgFieldDefs.length > 0) {
                            const firstArray = batchData.data.orgFieldDefs[0];
                            if (Array.isArray(firstArray)) {
                                orgFieldDefsTemplate = firstArray.map(f => ({ originalType: f.originalType, fieldName: f.fieldName }));
                            }
                        }

                        // ---------- 修复点:写表头时不要把 fieldDefs 传入(避免 Date 类型把表头解析为空) ----------
                        if (batchIndex === 0 && tableHeaders.length > 0) {
                            const headerRow = [tableHeaders];
                            // 这里传 null 作为 fieldDefs,确保表头按普通字符串写入
                            await pushRowsToCsvChunks(headerRow, null, csvChunks);
                            totalWritten += headerRow.length;
                            if (window.__updateDownloadProgress) {
                                const pct = totalCount > 0 ? (totalWritten / totalCount) * 100 : 100;
                                window.__updateDownloadProgress(pct, `已写入 ${totalWritten}/${totalCount} 行`);
                            }
                            console.log('已写入表头:', tableHeaders);
                        }

                        const rows = convertBatchToRows(batchData.data.data);
                        if (rows.length > 0) {
                            const wrote = await pushRowsToCsvChunks(rows, orgFieldDefsTemplate, csvChunks);
                            totalWritten += wrote;
                            console.log(`第 ${batchIndex + 1} 批写入 ${wrote} 行(累计 ${totalWritten} 行)`);
                        } else {
                            console.log(`第 ${batchIndex + 1} 批无数据`);
                        }
                    } else {
                        console.error(`第 ${batchIndex + 1} 批请求失败`, res.reason || res.value);
                    }
                }

                await new Promise(resolve => setTimeout(resolve, 0));
            }

            console.log(`所有批次处理完成,累计写入 ${totalWritten} 行`);
            if (totalWritten === 0) {
                alert('未获取到任何有效数据');
                return;
            }

            // 生成文件名及保存
            let datasetName = 'Result';
            const now = new Date();
            const timestamp = now.getFullYear() +
                  ('0' + (now.getMonth() + 1)).slice(-2) +
                  ('0' + now.getDate()).slice(-2) +
                  ('0' + now.getHours()).slice(-2) +
                  ('0' + now.getMinutes()).slice(-2) +
                  ('0' + now.getSeconds()).slice(-2);
            const fileName = `${(datasetName || 'Result')}-${timestamp}.csv`;

            console.log('开始生成 CSV Blob 并下载(流式)...');
            saveCsvChunksAsFile(csvChunks, fileName);
            alert(`CSV 导出完成: ${fileName}\n共写入 ${totalWritten} 行`);

        } catch (err) {
            console.error('导出失败', err);
            alert('导出失败: ' + (err && err.message ? err.message : err));
        }
    }

    // ---------- 初始化 ----------
    function initScript() {
        ensureLogWindow();
        createFloatingButton();
        console.log('脚本初始化完成(CSV 流式导出版 v1.5.1)');
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', initScript);
    } else {
        initScript();
    }
    window.addEventListener('load', function() {
        setTimeout(() => {
            if (!document.getElementById('download-step-container')) initScript();
        }, 1000);
    });
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址