FineReport Table Data Extractor

从FineReport表格中提取数据并在右侧边栏展示

Size

21.4 KB

Version

1.1.1

Created

Jan 26, 2026

Updated

9 days ago

1// ==UserScript==
2// @name		FineReport Table Data Extractor
3// @description		从FineReport表格中提取数据并在右侧边栏展示
4// @version		1.1.1
5// @match		https://*.demo.fanruan.com/*
6// @icon		https://cdn.fanruanclub.com/prod/dist/favicon.ico
7// @grant		GM.getValue
8// @grant		GM.setValue
9// ==/UserScript==
10(function() {
11    'use strict';
12
13    console.log('FineReport Table Data Extractor 已启动');
14
15    // 防抖函数
16    function debounce(func, wait) {
17        let timeout;
18        return function executedFunction(...args) {
19            const later = () => {
20                clearTimeout(timeout);
21                func(...args);
22            };
23            clearTimeout(timeout);
24            timeout = setTimeout(later, wait);
25        };
26    }
27
28    // 创建右侧边栏
29    function createSidebar() {
30        // 检查是否已存在
31        if (document.getElementById('finereport-sidebar')) {
32            return document.getElementById('finereport-sidebar');
33        }
34
35        const sidebar = document.createElement('div');
36        sidebar.id = 'finereport-sidebar';
37        sidebar.innerHTML = `
38            <div class="sidebar-header">
39                <h3>表格数据提取</h3>
40                <button id="close-sidebar" class="close-btn">×</button>
41            </div>
42            <div class="sidebar-content" id="sidebar-content">
43                <p class="placeholder">等待检测表格数据...</p>
44            </div>
45        `;
46
47        // 添加样式
48        const style = document.createElement('style');
49        style.textContent = `
50            #finereport-sidebar {
51                position: fixed;
52                right: 0;
53                top: 0;
54                width: 400px;
55                height: 100vh;
56                background: #ffffff;
57                box-shadow: -2px 0 10px rgba(0,0,0,0.1);
58                z-index: 999999;
59                display: flex;
60                flex-direction: column;
61                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
62                transition: transform 0.3s ease;
63            }
64
65            #finereport-sidebar.hidden {
66                transform: translateX(100%);
67            }
68
69            .sidebar-header {
70                background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
71                color: white;
72                padding: 20px;
73                display: flex;
74                justify-content: space-between;
75                align-items: center;
76                box-shadow: 0 2px 4px rgba(0,0,0,0.1);
77            }
78
79            .sidebar-header h3 {
80                margin: 0;
81                font-size: 18px;
82                font-weight: 600;
83            }
84
85            .close-btn {
86                background: rgba(255,255,255,0.2);
87                border: none;
88                color: white;
89                font-size: 24px;
90                cursor: pointer;
91                width: 32px;
92                height: 32px;
93                border-radius: 50%;
94                display: flex;
95                align-items: center;
96                justify-content: center;
97                transition: background 0.2s;
98            }
99
100            .close-btn:hover {
101                background: rgba(255,255,255,0.3);
102            }
103
104            .sidebar-content {
105                flex: 1;
106                overflow-y: auto;
107                padding: 20px;
108            }
109
110            .placeholder {
111                color: #999;
112                text-align: center;
113                padding: 40px 20px;
114                font-size: 14px;
115            }
116
117            .table-info {
118                margin-bottom: 30px;
119                background: #f8f9fa;
120                border-radius: 8px;
121                padding: 15px;
122                border-left: 4px solid #667eea;
123            }
124
125            .table-info h4 {
126                margin: 0 0 15px 0;
127                color: #333;
128                font-size: 16px;
129                font-weight: 600;
130            }
131
132            .info-item {
133                margin-bottom: 10px;
134                font-size: 13px;
135            }
136
137            .info-label {
138                font-weight: 600;
139                color: #555;
140                display: inline-block;
141                min-width: 80px;
142            }
143
144            .info-value {
145                color: #333;
146            }
147
148            .data-table {
149                width: 100%;
150                border-collapse: collapse;
151                margin-top: 10px;
152                font-size: 12px;
153                background: white;
154                border-radius: 4px;
155                overflow: hidden;
156            }
157
158            .data-table th {
159                background: #667eea;
160                color: white;
161                padding: 8px;
162                text-align: left;
163                font-weight: 600;
164            }
165
166            .data-table td {
167                padding: 8px;
168                border-bottom: 1px solid #e9ecef;
169                color: #333;
170            }
171
172            .data-table tr:last-child td {
173                border-bottom: none;
174            }
175
176            .data-table tr:hover {
177                background: #f8f9fa;
178            }
179
180            .export-btn {
181                background: #667eea;
182                color: white;
183                border: none;
184                padding: 8px 16px;
185                border-radius: 4px;
186                cursor: pointer;
187                font-size: 13px;
188                margin-top: 10px;
189                transition: background 0.2s;
190            }
191
192            .export-btn:hover {
193                background: #5568d3;
194            }
195
196            .toggle-sidebar-btn {
197                position: fixed;
198                right: 410px;
199                top: 50%;
200                transform: translateY(-50%);
201                background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
202                color: white;
203                border: none;
204                padding: 12px 8px;
205                cursor: pointer;
206                z-index: 999998;
207                border-radius: 4px 0 0 4px;
208                font-size: 12px;
209                writing-mode: vertical-rl;
210                text-orientation: mixed;
211                box-shadow: -2px 0 10px rgba(0,0,0,0.1);
212                transition: right 0.3s ease;
213            }
214
215            .toggle-sidebar-btn.sidebar-hidden {
216                right: 0;
217            }
218
219            .toggle-sidebar-btn:hover {
220                opacity: 0.9;
221            }
222
223            .report-source {
224                font-size: 11px;
225                color: #888;
226                margin-top: 5px;
227                font-style: italic;
228            }
229        `;
230        document.head.appendChild(style);
231        document.body.appendChild(sidebar);
232
233        // 添加切换按钮
234        const toggleBtn = document.createElement('button');
235        toggleBtn.id = 'toggle-sidebar-btn';
236        toggleBtn.className = 'toggle-sidebar-btn';
237        toggleBtn.textContent = '数据提取';
238        document.body.appendChild(toggleBtn);
239
240        // 关闭按钮事件
241        document.getElementById('close-sidebar').addEventListener('click', () => {
242            sidebar.classList.add('hidden');
243            toggleBtn.classList.add('sidebar-hidden');
244        });
245
246        // 切换按钮事件
247        toggleBtn.addEventListener('click', () => {
248            sidebar.classList.toggle('hidden');
249            toggleBtn.classList.toggle('sidebar-hidden');
250        });
251
252        console.log('侧边栏已创建');
253        return sidebar;
254    }
255
256    // 提取表格数据
257    function extractTableData(table, index, source = '主页面') {
258        console.log('正在提取表格数据:', index, '来源:', source);
259        
260        const headers = [];
261        const rows = [];
262        
263        // 提取表头
264        const headerCells = table.querySelectorAll('thead th, thead td, tr:first-child th, tr:first-child td');
265        if (headerCells.length > 0) {
266            headerCells.forEach(cell => {
267                headers.push(cell.textContent.trim());
268            });
269        }
270
271        // 提取数据行
272        const dataRows = table.querySelectorAll('tbody tr, tr');
273        let rowCount = 0;
274        dataRows.forEach((row, rowIndex) => {
275            // 跳过表头行
276            if (rowIndex === 0 && headerCells.length > 0 && row.querySelector('th')) {
277                return;
278            }
279            
280            const cells = row.querySelectorAll('td, th');
281            if (cells.length > 0) {
282                const rowData = [];
283                cells.forEach(cell => {
284                    rowData.push(cell.textContent.trim());
285                });
286                rows.push(rowData);
287                rowCount++;
288            }
289        });
290
291        return {
292            index: index + 1,
293            headers: headers,
294            rows: rows,
295            rowCount: rowCount,
296            columnCount: headers.length || (rows.length > 0 ? rows[0].length : 0),
297            source: source
298        };
299    }
300
301    // 更新侧边栏内容
302    function updateSidebar(tablesData) {
303        const content = document.getElementById('sidebar-content');
304        if (!content) return;
305
306        if (tablesData.length === 0) {
307            content.innerHTML = '<p class="placeholder">未检测到表格数据</p>';
308            return;
309        }
310
311        console.log('更新侧边栏,表格数量:', tablesData.length);
312
313        let html = '';
314        tablesData.forEach(tableData => {
315            html += `
316                <div class="table-info">
317                    <h4>表格 ${tableData.index}</h4>
318                    <div class="report-source">来源: ${tableData.source}</div>
319                    <div class="info-item">
320                        <span class="info-label">行数:</span>
321                        <span class="info-value">${tableData.rowCount}</span>
322                    </div>
323                    <div class="info-item">
324                        <span class="info-label">列数:</span>
325                        <span class="info-value">${tableData.columnCount}</span>
326                    </div>
327            `;
328
329            // 显示表头
330            if (tableData.headers.length > 0) {
331                html += `
332                    <div class="info-item">
333                        <span class="info-label">表头:</span>
334                        <span class="info-value">${tableData.headers.join(', ')}</span>
335                    </div>
336                `;
337            }
338
339            // 显示前5行数据预览
340            if (tableData.rows.length > 0) {
341                html += '<table class="data-table"><thead><tr>';
342                
343                // 表头
344                if (tableData.headers.length > 0) {
345                    tableData.headers.forEach(header => {
346                        html += `<th>${header || '-'}</th>`;
347                    });
348                } else if (tableData.rows[0]) {
349                    tableData.rows[0].forEach((_, i) => {
350                        html += `<th>列${i + 1}</th>`;
351                    });
352                }
353                
354                html += '</tr></thead><tbody>';
355                
356                // 数据行(最多显示5行)
357                const previewRows = tableData.rows.slice(0, 5);
358                previewRows.forEach(row => {
359                    html += '<tr>';
360                    row.forEach(cell => {
361                        html += `<td>${cell || '-'}</td>`;
362                    });
363                    html += '</tr>';
364                });
365                
366                html += '</tbody></table>';
367                
368                if (tableData.rows.length > 5) {
369                    html += `<p style="font-size: 12px; color: #666; margin-top: 8px;">显示前5行,共${tableData.rows.length}行</p>`;
370                }
371            }
372
373            html += `
374                    <button class="export-btn" data-table-index="${tableData.index - 1}">导出为CSV</button>
375                </div>
376            `;
377        });
378
379        content.innerHTML = html;
380
381        // 添加导出按钮事件
382        content.querySelectorAll('.export-btn').forEach(btn => {
383            btn.addEventListener('click', function() {
384                const tableIndex = parseInt(this.getAttribute('data-table-index'));
385                exportToCSV(tablesData[tableIndex]);
386            });
387        });
388    }
389
390    // 导出为CSV
391    function exportToCSV(tableData) {
392        console.log('导出CSV:', tableData.index);
393        
394        let csv = '';
395        
396        // 添加表头
397        if (tableData.headers.length > 0) {
398            csv += tableData.headers.map(h => `"${h}"`).join(',') + '\n';
399        }
400        
401        // 添加数据行
402        tableData.rows.forEach(row => {
403            csv += row.map(cell => `"${cell}"`).join(',') + '\n';
404        });
405
406        // 创建下载
407        const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
408        const link = document.createElement('a');
409        link.href = URL.createObjectURL(blob);
410        link.download = `finereport_table_${tableData.index}_${Date.now()}.csv`;
411        link.click();
412        
413        console.log('CSV导出完成');
414    }
415
416    // 扫描指定文档中的表格
417    function scanTablesInDocument(doc, source = '主页面') {
418        const tables = [];
419        try {
420            doc.querySelectorAll('table').forEach(table => {
421                // 过滤掉太小的表格(可能是布局用的)
422                if (table.rows && table.rows.length > 0) {
423                    tables.push({ table, source });
424                }
425            });
426        } catch (e) {
427            console.log('扫描表格时出错:', e.message);
428        }
429        return tables;
430    }
431
432    // 扫描所有iframe中的表格
433    function scanIframes() {
434        const allTables = [];
435        
436        document.querySelectorAll('iframe').forEach((iframe, iframeIndex) => {
437            try {
438                const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
439                if (iframeDoc) {
440                    const iframeName = iframe.name || iframe.src || `iframe-${iframeIndex}`;
441                    console.log('扫描iframe:', iframeName);
442                    
443                    const tables = scanTablesInDocument(iframeDoc, `iframe: ${iframeName}`);
444                    allTables.push(...tables);
445                    
446                    // 递归扫描iframe中的iframe
447                    iframeDoc.querySelectorAll('iframe').forEach((nestedIframe, nestedIndex) => {
448                        try {
449                            const nestedDoc = nestedIframe.contentDocument || nestedIframe.contentWindow.document;
450                            if (nestedDoc) {
451                                const nestedName = nestedIframe.name || nestedIframe.src || `nested-iframe-${nestedIndex}`;
452                                const nestedTables = scanTablesInDocument(nestedDoc, `嵌套iframe: ${nestedName}`);
453                                allTables.push(...nestedTables);
454                            }
455                        } catch (e) {
456                            console.log('无法访问嵌套iframe(跨域限制):', nestedIframe.src);
457                        }
458                    });
459                }
460            } catch (e) {
461                console.log('无法访问iframe内容(跨域限制):', iframe.src);
462            }
463        });
464        
465        return allTables;
466    }
467
468    // 扫描所有弹框/模态框中的表格
469    function scanModals() {
470        const allTables = [];
471        const modalSelectors = [
472            '.modal', '.dialog', '.popup', 
473            '[class*="modal"]', '[class*="dialog"]', '[class*="popup"]',
474            '[role="dialog"]', '[aria-modal="true"]'
475        ];
476        
477        modalSelectors.forEach(selector => {
478            try {
479                document.querySelectorAll(selector).forEach((modal, index) => {
480                    // 只扫描可见的弹框
481                    if (modal.offsetParent !== null || window.getComputedStyle(modal).display !== 'none') {
482                        const tables = scanTablesInDocument(modal, `弹框-${selector}-${index}`);
483                        allTables.push(...tables);
484                    }
485                });
486            } catch (e) {
487                console.log('扫描弹框时出错:', e.message);
488            }
489        });
490        
491        return allTables;
492    }
493
494    // 扫描页面中的所有表格
495    function scanTables() {
496        console.log('开始扫描页面中的表格...');
497        
498        const allTables = [];
499        
500        // 1. 扫描主文档中的表格
501        const mainTables = scanTablesInDocument(document, '主页面');
502        allTables.push(...mainTables);
503        console.log('主页面找到表格:', mainTables.length);
504        
505        // 2. 扫描所有iframe
506        const iframeTables = scanIframes();
507        allTables.push(...iframeTables);
508        console.log('iframe中找到表格:', iframeTables.length);
509        
510        // 3. 扫描所有弹框/模态框
511        const modalTables = scanModals();
512        allTables.push(...modalTables);
513        console.log('弹框中找到表格:', modalTables.length);
514
515        console.log('总共找到表格数量:', allTables.length);
516
517        if (allTables.length > 0) {
518            const tablesData = allTables.map((item, index) => 
519                extractTableData(item.table, index, item.source)
520            );
521            updateSidebar(tablesData);
522        } else {
523            updateSidebar([]);
524        }
525    }
526
527    // 监听DOM变化
528    function observeDOM() {
529        const debouncedScan = debounce(scanTables, 1000);
530        
531        const observer = new MutationObserver((mutations) => {
532            // 检查是否有表格相关的变化
533            let shouldScan = false;
534            for (const mutation of mutations) {
535                if (mutation.addedNodes.length > 0) {
536                    mutation.addedNodes.forEach(node => {
537                        if (node.nodeType === 1) { // 元素节点
538                            // 检查是否是表格、iframe或弹框
539                            if (node.tagName === 'TABLE' || 
540                                node.tagName === 'IFRAME' ||
541                                node.querySelector('table') ||
542                                node.querySelector('iframe') ||
543                                node.classList.contains('modal') ||
544                                node.classList.contains('dialog') ||
545                                node.classList.contains('popup') ||
546                                node.getAttribute('role') === 'dialog') {
547                                shouldScan = true;
548                            }
549                        }
550                    });
551                }
552                
553                // 检查属性变化(如弹框显示/隐藏)
554                if (mutation.type === 'attributes' && 
555                    (mutation.attributeName === 'style' || 
556                     mutation.attributeName === 'class' ||
557                     mutation.attributeName === 'aria-hidden')) {
558                    const target = mutation.target;
559                    if (target.querySelector && target.querySelector('table')) {
560                        shouldScan = true;
561                    }
562                }
563            }
564            
565            if (shouldScan) {
566                console.log('检测到页面内容变化,重新扫描表格');
567                debouncedScan();
568            }
569        });
570
571        observer.observe(document.body, {
572            childList: true,
573            subtree: true,
574            attributes: true,
575            attributeFilter: ['style', 'class', 'aria-hidden']
576        });
577
578        console.log('DOM监听已启动');
579    }
580
581    // 监听iframe加载
582    function observeIframes() {
583        console.log('开始监听iframe加载');
584        
585        // 监听现有iframe
586        document.querySelectorAll('iframe').forEach(iframe => {
587            iframe.addEventListener('load', () => {
588                console.log('iframe加载完成:', iframe.src || iframe.name);
589                setTimeout(scanTables, 1000); // 延迟扫描,确保内容加载完成
590            });
591        });
592        
593        // 监听新添加的iframe
594        const iframeObserver = new MutationObserver((mutations) => {
595            mutations.forEach(mutation => {
596                mutation.addedNodes.forEach(node => {
597                    if (node.nodeType === 1 && node.tagName === 'IFRAME') {
598                        console.log('检测到新iframe');
599                        node.addEventListener('load', () => {
600                            console.log('新iframe加载完成:', node.src || node.name);
601                            setTimeout(scanTables, 1000);
602                        });
603                    }
604                });
605            });
606        });
607        
608        iframeObserver.observe(document.body, {
609            childList: true,
610            subtree: true
611        });
612    }
613
614    // 初始化
615    function init() {
616        console.log('初始化 FineReport Table Data Extractor');
617        
618        // 等待页面加载完成
619        if (document.readyState === 'loading') {
620            document.addEventListener('DOMContentLoaded', () => {
621                createSidebar();
622                setTimeout(scanTables, 2000); // 延迟初始扫描
623                observeDOM();
624                observeIframes();
625            });
626        } else {
627            createSidebar();
628            setTimeout(scanTables, 2000); // 延迟初始扫描
629            observeDOM();
630            observeIframes();
631        }
632
633        // 定期扫描(处理动态加载的内容)
634        setInterval(() => {
635            console.log('定期扫描表格...');
636            scanTables();
637        }, 5000);
638    }
639
640    // 启动
641    init();
642
643})();
FineReport Table Data Extractor | Robomonkey