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})();