Greasy Fork

Greasy Fork is available in English.

通用表格排序/筛选/导出(Excel风格)

为任意网页表格提供排序、筛选、全局搜索与CSV导出。自动识别文本/数值/日期列,多表选择,三态排序,联合筛选,导出当前可见行。

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         通用表格排序/筛选/导出(Excel风格)
// @namespace    table.tools.universal.excel.like
// @version      1.0.0
// @description  为任意网页表格提供排序、筛选、全局搜索与CSV导出。自动识别文本/数值/日期列,多表选择,三态排序,联合筛选,导出当前可见行。
// @author       Frankie
// @match        *://*/*
// @run-at       document-idle
// @grant        GM_addStyle
// @license      MIT License with Attribution
// ==/UserScript==
 
// Additional clause:
// 1. Any redistribution or modification must retain the original donation link and cannot remove or modify it.

(function () {
  'use strict';

  // ============ 样式 ============
  GM_addStyle(`
    .tt-btn {
      position: fixed; z-index: 999999; top: 14px; right: 14px;
      padding: 8px 12px; border-radius: 10px; cursor: pointer;
      border: 1px solid rgba(0,0,0,0.12); background: #fff; font-weight: 600;
      box-shadow: 0 6px 18px rgba(0,0,0,0.08);
      font-family: system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial;
    }
    .tt-panel {
      position: fixed; z-index: 1000000; top: 64px; right: 14px;
      width: min(1100px, 96vw); max-height: 80vh; overflow: auto;
      background: #fff; border: 1px solid rgba(0,0,0,0.12); border-radius: 12px;
      box-shadow: 0 10px 26px rgba(0,0,0,0.12);
      padding: 14px; font-family: system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial;
    }
    .tt-row { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin-bottom: 10px; }
    .tt-row .span1{ grid-column: span 1; }
    .tt-row .span2{ grid-column: span 2; }
    .tt-row .span3{ grid-column: span 3; }
    .tt-row .span4{ grid-column: span 4; }
    .tt-row .span6{ grid-column: span 6; }
    .tt-panel input, .tt-panel select, .tt-panel button, .tt-panel label {
      padding: 6px 8px; border: 1px solid #ddd; border-radius: 8px; background: #fafafa; width: 100%;
      box-sizing: border-box;
    }
    .tt-panel button { cursor: pointer; }
    .tt-muted { opacity: .7; border: 0; background: transparent; padding: 0; }
    .tt-tagbar { display:flex; flex-wrap: wrap; gap: 6px; }
    .tt-tag { padding: 4px 8px; border-radius: 999px; border: 1px solid #ddd; background: #f8f8f8; cursor: pointer; user-select: none; }
    .tt-tag.active { background: #1a73e8; color: #fff; border-color: #1a73e8; }

    .tt-table { width:100%; border-collapse: collapse; font-size: 12px; }
    .tt-table th, .tt-table td { padding: 6px 8px; border-bottom: 1px solid #eee; text-align: left; }
    .tt-table thead th { position: sticky; top: 0; background: #fafafa; }
    .tt-right { text-align: right; }

    .tt-chip { display:inline-block; padding:2px 6px; border-radius:999px; background:#f5f5f5; font-size:11px; border:1px solid #eee; }
    .tt-hint { font-size: 12px; color: #666; }
    .tt-colbox { border:1px solid #eee; border-radius:8px; padding:8px; background:#fcfcfc; }
    .tt-flex { display:flex; gap:8px; align-items:center; }
    .tt-col-grid { display:grid; grid-template-columns: repeat(12, 1fr); gap:6px; }
    .tt-col-grid > div { grid-column: span 4; }
    .tt-col-grid .wide { grid-column: span 12; }
  `);

  // ============ 工具 ============
  const sleep = (ms) => new Promise(r => setTimeout(r, ms));
  const isNum = (v) => /^-?\d+(?:\.\d+)?$/.test(String(v).trim().replace(/,/g,''));
  const toNum = (v) => Number(String(v).trim().replace(/,/g,''));
  const isDateLike = (s) => !isNaN(Date.parse(s));
  const cmp = (a,b) => (a<b?-1:(a>b?1:0));

  function detectType(samples) {
    // 简单启发式:多数样本是数值 => number;多数是可解析日期 => date;否则 text
    let n=0, d=0, t=0;
    for (const s of samples) {
      const v = (s??'').toString().trim();
      if (!v) { t++; continue; }
      if (isNum(v)) n++; else if (isDateLike(v)) d++; else t++;
    }
    if (n >= d && n >= t) return 'number';
    if (d >= n && d >= t) return 'date';
    return 'text';
  }

  function getText(el) {
    return (el?.textContent || '').replace(/\s+/g,' ').trim();
  }

  function uniqueValues(arr, limit=2000) {
    const set = new Set();
    for (const v of arr) { set.add(v); if (set.size > limit) break; }
    return Array.from(set);
  }

  function toCSV(rows) {
    const esc = (s) => {
      const str = s==null? '' : String(s);
      return /[",\n]/.test(str) ? `"${str.replace(/"/g,'""')}"` : str;
    };
    return rows.map(r=>r.map(esc).join(',')).join('\n');
  }

  // ============ 主逻辑 ============
  // 扫描页面中可用表格
  function scanTables() {
    const tables = Array.from(document.querySelectorAll('table'))
      .filter(tb => tb.querySelector('tbody tr') && tb.querySelector('thead th'));
    return tables.map((tb, idx) => {
      const heads = Array.from(tb.querySelectorAll('thead th')).map(getText);
      const rows = Array.from(tb.querySelectorAll('tbody tr'))
        .filter(tr => tr.offsetParent !== null); // 只要可见行
      return { tb, idx, heads, rowCount: rows.length };
    });
  }

  // 从 table 提取二维数组数据
  function extractData(tb) {
    const heads = Array.from(tb.querySelectorAll('thead th')).map(getText);
    const trs = Array.from(tb.querySelectorAll('tbody tr'));
    const data = [];
    for (const tr of trs) {
      const tds = Array.from(tr.querySelectorAll('td'));
      const row = tds.map(td => getText(td));
      data.push({ tr, row });
    }
    return { heads, data };
  }

  // 构建列元信息(类型、枚举、小样本等)
  function buildColumnMeta(heads, data) {
    const samplesPerCol = heads.map(()=>[]);
    const valuePerCol = heads.map(()=>[]);
    for (let i=0;i<data.length;i++) {
      const row = data[i].row;
      for (let c=0;c<heads.length;c++) {
        const v = row[c] ?? '';
        if (samplesPerCol[c].length < 50) samplesPerCol[c].push(v);
        valuePerCol[c].push(v);
      }
    }
    const meta = heads.map((h,c) => {
      const type = detectType(samplesPerCol[c]);
      const uniques = uniqueValues(valuePerCol[c], 5000);
      const enumCap = 30;
      const useEnum = (type==='text' && uniques.length>0 && uniques.length<=enumCap);
      return { name:h, type, uniques: useEnum ? uniques.sort((a,b)=>a.localeCompare(b)) : null };
    });
    return meta;
  }

  // 过滤函数构建
  function makeFilterFns(filters, meta) {
    // filters: { globalQ, byCol: { c: {type, textQ, enumSel:Set, min, max} } }
    const glbKeys = (filters.globalQ||'').trim().toLowerCase().split(/\s+/).filter(Boolean);
    const byCol = filters.byCol || {};
    return (row) => {
      // 全局关键词:任意单元包含全部关键词
      if (glbKeys.length) {
        const joined = row.join(' ').toLowerCase();
        for (const k of glbKeys) {
          if (!joined.includes(k)) return false;
        }
      }
      // 按列过滤
      for (const cStr of Object.keys(byCol)) {
        const c = Number(cStr);
        const f = byCol[c]; if (!f) continue;
        const vRaw = row[c] ?? '';
        const v = vRaw.trim();

        if (f.type==='text') {
          if (f.enumSel && f.enumSel.size) {
            if (!f.enumSel.has(v)) return false;
          }
          if (f.textQ && f.textQ.length) {
            const vs = v.toLowerCase();
            for (const k of f.textQ) {
              if (!vs.includes(k)) return false;
            }
          }
        } else if (f.type==='number') {
          const numable = isNum(v);
          const val = numable ? toNum(v) : NaN;
          if (f.min!=null && !(numable && val >= f.min)) return false;
          if (f.max!=null && !(numable && val <= f.max)) return false;
        } else if (f.type==='date') {
          const t = Date.parse(v);
          if (f.min!=null && !(isFinite(t) && t >= f.min)) return false;
          if (f.max!=null && !(isFinite(t) && t <= f.max)) return false;
        }
      }
      return true;
    };
  }

  // 排序函数构建
  function makeSortFn(sort, meta) {
    // sort: { col, dir: 1|-1|0 }
    if (!sort || sort.dir===0 || sort.col==null) return null;
    const c = sort.col, dir = sort.dir;
    const type = meta[c]?.type || 'text';
    if (type==='number') {
      return (a,b) => {
        const av = isNum(a[c]) ? toNum(a[c]) : NaN;
        const bv = isNum(b[c]) ? toNum(b[c]) : NaN;
        if (isNaN(av) && isNaN(bv)) return 0;
        if (isNaN(av)) return 1; // NaN放后
        if (isNaN(bv)) return -1;
        return dir * (av - bv);
      };
    } else if (type==='date') {
      return (a,b) => dir * (Date.parse(a[c]) - Date.parse(b[c]));
    } else {
      return (a,b) => dir * cmp(String(a[c]||''), String(b[c]||''));
    }
  }

  // 将过滤/排序结果应用到原始表格(仅显示/隐藏,不改数据)
  function applyToTable(tb, heads, data, filterFn, sortFn) {
    // 先全部显示
    for (const {tr} of data) { tr.style.display = ''; }

    // 过滤
    const filtered = [];
    for (const rec of data) {
      if (filterFn(rec.row)) filtered.push(rec);
      else rec.tr.style.display = 'none';
    }

    // 排序:若有排序,按DOM顺序重新插入
    if (sortFn) {
      const sorted = filtered.slice().sort((A,B) => sortFn(A.row, B.row));
      const tbody = tb.querySelector('tbody');
      for (const rec of sorted) tbody.appendChild(rec.tr);
      return { visible: sorted.length };
    }
    return { visible: filtered.length };
  }

  // ============ UI ============
  let STATE = {
    tables: [],
    curIdx: 0,
    heads: [],
    data: [],
    meta: [],
    filters: { globalQ: '', byCol: {} },
    sort: { col: null, dir: 0 }, // 0无序, 1升序, -1降序
  };

  function ensureButton() {
    if (document.querySelector('.tt-btn')) return;
    const b = document.createElement('button');
    b.className = 'tt-btn';
    b.textContent = '🔎 Table Tools';
    b.onclick = togglePanel;
    document.body.appendChild(b);
  }

  function togglePanel() {
    const panel = document.querySelector('.tt-panel');
    if (panel) { panel.remove(); return; }
    openPanel();
  }

  async function openPanel() {
    STATE.tables = scanTables();
    if (!STATE.tables.length) {
      alert('未发现可用表格(需要带 thead/tbody 的 <table>)。');
      return;
    }
    if (STATE.curIdx >= STATE.tables.length) STATE.curIdx = 0;

    // 初次加载当前表
    await loadCurrentTable();

    const p = document.createElement('div');
    p.className = 'tt-panel';
    p.innerHTML = `
      <h3>通用表格工具(排序 / 筛选 / 导出 CSV)</h3>
      <div class="tt-row">
        <div class="span2">
          <label class="tt-muted">选择表格</label>
          <select id="tt-table"></select>
        </div>
        <div class="span4">
          <label class="tt-muted">全局搜索(空格分词:需全部命中)</label>
          <input id="tt-global" placeholder="在全部列里模糊搜索">
        </div>
        <div class="span2">
          <label class="tt-muted">排序字段</label>
          <select id="tt-sort-col"></select>
        </div>
        <div class="span2">
          <label class="tt-muted">排序方向</label>
          <select id="tt-sort-dir">
            <option value="0">不排序</option>
            <option value="1">升序</option>
            <option value="-1">降序</option>
          </select>
        </div>
        <div class="span2">
          <label class="tt-muted">可见行数</label>
          <button id="tt-stat" disabled>—</button>
        </div>
      </div>

      <div class="tt-colbox">
        <div class="tt-hint">按列筛选(自动识别类型:文本/枚举/数值/日期)</div>
        <div class="tt-col-grid" id="tt-cols"></div>
        <div class="tt-flex" style="margin-top:8px;">
          <button id="tt-reset">重置筛选</button>
          <button id="tt-export">导出当前可见为 CSV</button>
          <span class="tt-muted">小贴士:文本列支持“多关键字 AND”——用空格分隔关键字;数值/日期支持范围。</span>
        </div>
      </div>
    `;
    document.body.appendChild(p);

    // 初始化表选择
    const sel = p.querySelector('#tt-table');
    sel.innerHTML = STATE.tables.map(t =>
      `<option value="${t.idx}">表#${t.idx+1}(列:${t.heads.length} 行:${t.rowCount})</option>`
    ).join('');
    sel.value = String(STATE.curIdx);
    sel.onchange = async () => {
      STATE.curIdx = Number(sel.value);
      await loadCurrentTable();
      refreshPanel();
      applyAll();
    };

    // 全局与排序控件
    p.querySelector('#tt-global').value = STATE.filters.globalQ || '';
    p.querySelector('#tt-global').oninput = (e) => {
      STATE.filters.globalQ = e.target.value || '';
      applyAll();
    };

    const sortColSel = p.querySelector('#tt-sort-col');
    sortColSel.innerHTML = STATE.heads.map((h,i)=>`<option value="${i}">${h||('列'+(i+1))}</option>`).join('');
    sortColSel.value = STATE.sort.col==null ? '0' : String(STATE.sort.col);
    sortColSel.onchange = (e) => {
      STATE.sort.col = Number(e.target.value);
      applyAll();
    };

    const sortDirSel = p.querySelector('#tt-sort-dir');
    sortDirSel.value = String(STATE.sort.dir||0);
    sortDirSel.onchange = (e) => {
      STATE.sort.dir = Number(e.target.value);
      applyAll();
    };

    // 渲染列筛选区
    renderColFilters();

    // 按钮
    p.querySelector('#tt-reset').onclick = () => {
      STATE.filters = { globalQ: '', byCol: {} };
      STATE.sort = { col: null, dir: 0 };
      p.querySelector('#tt-global').value = '';
      p.querySelector('#tt-sort-dir').value = '0';
      p.querySelector('#tt-sort-col').value = '0';
      renderColFilters();
      applyAll();
    };

    p.querySelector('#tt-export').onclick = () => {
      const visibleRows = Array.from(STATE.data).filter(({tr}) => tr.style.display !== 'none');
      const rows = [STATE.heads].concat(visibleRows.map(r => r.row));
      const csv = toCSV(rows);
      const blob = new Blob([csv], {type:'text/csv;charset=utf-8;'});
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url; a.download = 'table_filtered.csv';
      document.body.appendChild(a); a.click(); a.remove();
      URL.revokeObjectURL(url);
    };

    applyAll(); // 首次应用
  }

  async function loadCurrentTable() {
    const info = scanTables().find(t => t.idx === STATE.curIdx) || scanTables()[0];
    if (!info) return;
    STATE.curIdx = info.idx;
    const { heads, data } = extractData(info.tb);
    const meta = buildColumnMeta(heads, data);

    STATE.heads = heads;
    STATE.data = data;
    STATE.meta = meta;

    // 如果当前排序列超出范围,重置
    if (STATE.sort.col==null || STATE.sort.col >= heads.length) {
      STATE.sort = { col: null, dir: 0 };
    }
  }

  function renderColFilters() {
    const wrap = document.querySelector('.tt-panel #tt-cols');
    if (!wrap) return;
    wrap.innerHTML = '';

    STATE.meta.forEach((m, c) => {
      const f = STATE.filters.byCol[c] || {};
      const box = document.createElement('div');
      box.innerHTML = `<div class="tt-colbox">
        <div class="tt-hint"><b>${m.name || ('列'+(c+1))}</b> <span class="tt-chip">${m.type}</span></div>
        <div class="tt-row" style="margin:6px 0 0 0;">
          ${m.type==='number' ? `
            <div class="span3"><label class="tt-muted">≥</label><input id="min-${c}" type="number" step="any" placeholder="最小" value="${f.min??''}"></div>
            <div class="span3"><label class="tt-muted">≤</label><input id="max-${c}" type="number" step="any" placeholder="最大" value="${f.max??''}"></div>
          ` : m.type==='date' ? `
            <div class="span3"><label class="tt-muted">起始</label><input id="min-${c}" type="datetime-local" value="${f.min? new Date(f.min).toISOString().slice(0,16):''}"></div>
            <div class="span3"><label class="tt-muted">结束</label><input id="max-${c}" type="datetime-local" value="${f.max? new Date(f.max).toISOString().slice(0,16):''}"></div>
          ` : (m.uniques ? `
            <div class="span6">
              <div class="tt-tagbar" id="enum-${c}"></div>
            </div>
          ` : `
            <div class="span6"><label class="tt-muted">包含关键字(空格分词,AND)</label><input id="q-${c}" placeholder="示例:abc def" value="${(f.textQ||[]).join(' ')}"></div>
          `)}
        </div>
      </div>`;
      wrap.appendChild(box);

      if (m.type === 'number') {
        box.querySelector(`#min-${c}`).oninput = (e) => {
          STATE.filters.byCol[c] = STATE.filters.byCol[c] || {type:'number'};
          STATE.filters.byCol[c].type='number';
          STATE.filters.byCol[c].min = e.target.value===''? null : Number(e.target.value);
          applyAll();
        };
        box.querySelector(`#max-${c}`).oninput = (e) => {
          STATE.filters.byCol[c] = STATE.filters.byCol[c] || {type:'number'};
          STATE.filters.byCol[c].type='number';
          STATE.filters.byCol[c].max = e.target.value===''? null : Number(e.target.value);
          applyAll();
        };
      } else if (m.type === 'date') {
        box.querySelector(`#min-${c}`).onchange = (e) => {
          STATE.filters.byCol[c] = STATE.filters.byCol[c] || {type:'date'};
          STATE.filters.byCol[c].type='date';
          STATE.filters.byCol[c].min = e.target.value? Date.parse(e.target.value) : null;
          applyAll();
        };
        box.querySelector(`#max-${c}`).onchange = (e) => {
          STATE.filters.byCol[c] = STATE.filters.byCol[c] || {type:'date'};
          STATE.filters.byCol[c].type='date';
          STATE.filters.byCol[c].max = e.target.value? Date.parse(e.target.value) : null;
          applyAll();
        };
      } else if (m.uniques) {
        const bar = box.querySelector(`#enum-${c}`);
        const sel = (STATE.filters.byCol[c]?.enumSel) || new Set();
        m.uniques.slice(0, 1000).forEach(val => {
          const tag = document.createElement('span');
          tag.className = 'tt-tag' + (sel.has(val)? ' active':'');
          tag.textContent = val || '(空)';
          tag.onclick = () => {
            STATE.filters.byCol[c] = STATE.filters.byCol[c] || {type:'text'};
            STATE.filters.byCol[c].type='text';
            const set = (STATE.filters.byCol[c].enumSel ||= new Set());
            if (set.has(val)) set.delete(val); else set.add(val);
            tag.classList.toggle('active');
            applyAll();
          };
          bar.appendChild(tag);
        });
      } else {
        box.querySelector(`#q-${c}`).oninput = (e) => {
          const words = (e.target.value||'').toLowerCase().split(/\s+/).filter(Boolean);
          if (!STATE.filters.byCol[c]) STATE.filters.byCol[c] = {type:'text'};
          STATE.filters.byCol[c].type='text';
          STATE.filters.byCol[c].textQ = words;
          applyAll();
        };
      }
    });
  }

  function refreshPanel() {
    const p = document.querySelector('.tt-panel'); if (!p) return;
    // 头部控件更新
    const sortColSel = p.querySelector('#tt-sort-col');
    sortColSel.innerHTML = STATE.heads.map((h,i)=>`<option value="${i}">${h||('列'+(i+1))}</option>`).join('');
    sortColSel.value = STATE.sort.col==null ? '0' : String(STATE.sort.col);

    // 列筛选重画
    renderColFilters();
  }

  function applyAll() {
    const info = scanTables().find(t => t.idx === STATE.curIdx);
    if (!info) return;
    const tb = info.tb;

    // 重抓一次数据(可能动态变化)
    const { heads, data } = extractData(tb);
    STATE.heads = heads; STATE.data = data;

    const filterFn = makeFilterFns(STATE.filters, STATE.meta);
    const sortFn = makeSortFn(STATE.sort, STATE.meta);
    const { visible } = applyToTable(tb, heads, data, filterFn, sortFn);

    const statBtn = document.querySelector('.tt-panel #tt-stat');
    if (statBtn) statBtn.textContent = `${visible}/${data.length}`;
  }

  // ============ 初始化 ============
  function init() {
    ensureButton();

    // 首次扫描一遍,若有表就预构建 meta
    const tables = scanTables();
    if (tables.length) {
      STATE.curIdx = 0;
      const { heads, data } = extractData(tables[0].tb);
      STATE.heads = heads;
      STATE.data = data;
      STATE.meta = buildColumnMeta(heads, data);
    }
  }

  // 监听 DOM 变化,尝试自动刷新(防抖)
  let busy = false, debTimer = null;
  const mo = new MutationObserver(() => {
    if (busy) return;
    if (debTimer) clearTimeout(debTimer);
    debTimer = setTimeout(async () => {
      busy = true;
      try {
        const pOpen = !!document.querySelector('.tt-panel');
        const tables = scanTables();
        if (!tables.length) return;

        // 如果当前表不存在了,切回第一个
        if (!tables.find(t => t.idx === STATE.curIdx)) STATE.curIdx = 0;

        // 重新加载当前表的 meta
        const cur = tables.find(t => t.idx === STATE.curIdx) || tables[0];
        const { heads, data } = extractData(cur.tb);
        STATE.heads = heads; STATE.data = data; STATE.meta = buildColumnMeta(heads, data);

        if (pOpen) {
          refreshPanel();
          applyAll();
        }
      } finally {
        busy = false;
      }
    }, 400);
  });

  init();
  mo.observe(document.documentElement || document.body, { childList: true, subtree: true });
})();