1026 lines
46 KiB
HTML
1026 lines
46 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
|
||
<title>{{REPORT_TITLE}} · 动态报表</title>
|
||
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
|
||
<!-- Chart.js loaded dynamically below -->
|
||
<style>
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
body{font-family:"Inter","PingFang SC","Microsoft YaHei",sans-serif;font-size:14px;min-height:100vh;transition:background .3s,color .3s}
|
||
|
||
:root{--bg:#0b0b0f;--bg2:#14141c;--bg3:#1a1a28;--bg4:#111118;--fg:#e8e6e3;--fg2:#d0ceca;--fg3:#9a97a0;--fg4:#6b6973;--bd:rgba(127,119,221,0.1);--bd2:rgba(127,119,221,0.06);--hl:#7F77DD;--hl2:#AFA9EC;--gr1:linear-gradient(135deg,#14141c,#1a1a28);--gr2:linear-gradient(135deg,#AFA9EC,#5DCAA5);--sh1:rgba(0,0,0,0.15);--pin-off:#6b6973;--pin-on:#7F77DD;--th-bg:#1a1a28;--th-border:rgba(127,119,221,0.15);--th-color:#b8b5c0}
|
||
.light{--bg:#f0ede8;--bg2:#faf8f5;--bg3:#f5f2ec;--bg4:#fdfcfa;--fg:#2a2826;--fg2:#4a4844;--fg3:#7a7770;--fg4:#a09d96;--bd:rgba(127,119,221,0.1);--bd2:rgba(127,119,221,0.05);--hl:#6B63C9;--hl2:#6B63C9;--gr1:linear-gradient(135deg,#faf8f5,#f0ede8);--gr2:linear-gradient(135deg,#6B63C9,#1D9E75);--sh1:rgba(0,0,0,0.04);--pin-off:#c5c2bb;--pin-on:#6B63C9;--th-bg:#e8e4de;--th-border:rgba(127,119,221,0.1);--th-color:#5a5854}
|
||
|
||
body{background:var(--bg);color:var(--fg)}
|
||
.app{display:flex;min-height:100vh}
|
||
.main{flex:1;min-width:0;display:flex;flex-direction:column}
|
||
.sidebar{width:0;overflow:hidden;background:var(--bg4);border-left:1px solid var(--bd);transition:width .25s;flex-shrink:0}
|
||
.sidebar.open{width:300px}
|
||
.sidebar-inner{width:300px;padding:20px 18px;overflow-y:auto;height:100vh}
|
||
|
||
.header{background:var(--gr1);border-bottom:1px solid var(--bd);padding:18px 20px 12px}
|
||
.header-top{display:flex;justify-content:space-between;align-items:flex-start;gap:8px}
|
||
.header h1{font-size:1.15rem;font-weight:600}
|
||
.header .sub{color:var(--fg3);font-size:0.78rem;margin-top:2px}
|
||
.header-right{text-align:right;font-size:0.72rem;color:var(--fg4);white-space:nowrap}
|
||
|
||
|
||
/* ===== Summary ===== */
|
||
.summary{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;padding:16px 20px 4px}
|
||
.card{background:var(--bg2);border:1px solid var(--bd);border-radius:12px;padding:16px 18px}
|
||
.card .l{font-size:0.75rem;color:var(--fg4);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px}
|
||
.card .v{font-size:1.4rem;font-weight:700;background:var(--gr2);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;margin:2px 0 4px}
|
||
.card .c{font-size:0.72rem;color:var(--fg4)}
|
||
|
||
/* ===== Charts ===== */
|
||
.charts-wrap{display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:12px;padding:0 20px 8px}
|
||
.chart-box{background:var(--bg2);border:1px solid var(--bd);border-radius:12px;padding:16px;position:relative}
|
||
.chart-box .chart-title{font-size:0.75rem;color:var(--fg4);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:8px}
|
||
.chart-box canvas{max-height:260px}
|
||
.chart-ctrls{display:flex;flex-wrap:wrap;gap:6px;align-items:center;padding:0 20px 4px}
|
||
.chart-ctrls label{font-size:0.7rem;color:var(--fg4);white-space:nowrap}
|
||
.chart-ctrls select,.chart-ctrls input{padding:4px 8px;background:var(--bg2);border:1px solid var(--bd);border-radius:6px;color:var(--fg);font-size:0.75rem;outline:none;font-family:inherit;cursor:pointer}
|
||
.chart-ctrls select option{background:var(--bg2);color:var(--fg)}
|
||
.chart-type-btn{padding:4px 8px;font-size:0.85rem;min-width:32px;justify-content:center}
|
||
.chart-type-btn.act{background:rgba(127,119,221,0.2);border-color:var(--hl);color:var(--hl2)}
|
||
.report-desc{padding:4px 20px 6px;font-size:0.78rem;color:var(--fg3);display:flex;gap:12px;flex-wrap:wrap;line-height:1.6}
|
||
.report-desc{padding:4px 20px 8px;font-size:0.72rem;color:var(--fg4);display:flex;gap:8px;flex-wrap:wrap}
|
||
|
||
.flt{display:flex;flex-wrap:wrap;gap:6px;padding:0 20px 8px}
|
||
.flt-g{display:flex;align-items:center;gap:4px;background:var(--bg2);border:1px solid var(--bd2);border-radius:6px;padding:3px 8px}
|
||
.flt-g label{font-size:0.7rem;color:var(--fg4);white-space:nowrap}
|
||
.flt-g input,.flt-g select{padding:4px 6px;background:transparent;border:none;color:var(--fg);font-size:0.8rem;outline:none;min-width:65px;font-family:inherit}
|
||
.flt-g select option{background:var(--bg2);color:var(--fg)}
|
||
.flt-g input::placeholder{color:var(--fg4);font-size:0.75rem}
|
||
.flt-g.wide{min-width:120px;flex:0.5}
|
||
|
||
.tb{display:flex;justify-content:space-between;align-items:center;gap:6px;padding:0 20px 6px;flex-wrap:wrap}
|
||
.tb-l,.tb-r{display:flex;gap:5px;flex-wrap:wrap;align-items:center}
|
||
.btn{padding:5px 12px;border-radius:6px;border:1px solid var(--bd);background:transparent;color:var(--fg3);font-size:0.75rem;cursor:pointer;transition:.2s;font-family:inherit;display:inline-flex;align-items:center;gap:4px}
|
||
.btn:hover{border-color:var(--hl);color:var(--fg);background:rgba(127,119,221,0.05)}
|
||
.btn.act{background:rgba(127,119,221,0.12);border-color:var(--hl);color:var(--hl2)}
|
||
.btn-excel{padding:5px 14px;border:none;border-radius:6px;background:linear-gradient(135deg,#1D9E75,#0F6E56);color:#fff;font-size:0.75rem;cursor:pointer;transition:.2s;font-family:inherit}
|
||
.btn-excel:hover{box-shadow:0 4px 12px rgba(29,158,117,0.25)}
|
||
.cnt{font-size:0.75rem;color:var(--fg4)}
|
||
.theme-btn{width:28px;height:28px;border-radius:50%;border:1px solid var(--bd);background:var(--bg2);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:0.8rem;transition:.2s}
|
||
.theme-btn:hover{border-color:var(--hl)}
|
||
.sb-title{font-size:0.85rem;font-weight:600;margin-bottom:16px}
|
||
.sb-sec{margin-bottom:18px}
|
||
.sb-sec-title{font-size:0.72rem;color:var(--fg4);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:8px}
|
||
.col-check{display:flex;align-items:center;gap:6px;padding:5px 6px;border-radius:5px;transition:.15s}
|
||
.col-check:hover{background:rgba(127,119,221,0.05)}
|
||
.col-check input[type=checkbox]{width:14px;height:14px;accent-color:var(--hl);cursor:pointer}
|
||
.col-check label{font-size:0.78rem;color:var(--fg3);cursor:pointer;flex:1}
|
||
.col-check .col-tag{font-size:0.65rem;padding:1px 6px;border-radius:4px;background:rgba(127,119,221,0.08);color:var(--hl)}
|
||
.drag-h{display:inline-flex;align-items:center;justify-content:center;width:24px;height:26px;cursor:grab;color:var(--fg3);font-size:1rem;flex-shrink:0;border-radius:4px;transition:.15s;user-select:none;font-weight:600}
|
||
.drag-h:hover{color:var(--hl2);background:rgba(127,119,221,0.12)}
|
||
.drag-h:active{cursor:grabbing}
|
||
.col-check.dragging{opacity:0.4;background:rgba(127,119,221,0.05)}
|
||
.col-check.drag-over-top{border-top:2px solid var(--hl)}
|
||
.col-check.drag-over-bottom{border-bottom:2px solid var(--hl)}
|
||
|
||
/* ===== Freeze ===== */
|
||
.fz-pin{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:4px;margin-left:4px;cursor:pointer;font-size:0.6rem;opacity:0;transition:.2s;vertical-align:middle;flex-shrink:0}
|
||
thead th:hover .fz-pin{opacity:0.4}
|
||
.fz-pin:hover{opacity:1!important;background:rgba(127,119,221,0.15)}
|
||
.fz-pin.on{opacity:0.7!important;background:rgba(127,119,221,0.12);color:var(--hl)}
|
||
thead th.sticky{position:sticky;z-index:11;background:var(--th-bg)}
|
||
thead th.sticky-l{left:0;border-right:1px solid var(--th-border)}
|
||
tbody td.sticky{position:sticky;z-index:2;background:var(--bg)}
|
||
tbody td.sticky-l{left:0;border-right:1px solid var(--bd);box-shadow:1px 0 4px var(--sh1)}
|
||
tbody tr:hover td.sticky{background:var(--bg2)}
|
||
.ts{overflow-x:auto;overflow-y:auto;max-height:72vh}
|
||
table{width:100%;border-collapse:separate;border-spacing:0;font-size:0.78rem;white-space:nowrap;table-layout:fixed}
|
||
thead{position:sticky;top:0;z-index:10}
|
||
thead th{padding:10px 10px;text-align:center;font-weight:600;font-size:0.78rem;color:var(--th-color);text-transform:uppercase;letter-spacing:0.3px;background:var(--th-bg);border-bottom:1px solid var(--th-border);cursor:pointer;user-select:none}
|
||
thead th:hover{color:var(--hl2)}
|
||
thead th .sa{display:inline-block;margin-left:2px;font-size:0.55rem;opacity:0.2}
|
||
thead th:hover .sa{opacity:0.5}
|
||
thead th.sa-up .sa::after{content:'▲';color:var(--hl);opacity:1}
|
||
thead th.sa-dn .sa::after{content:'▼';color:var(--hl);opacity:1}
|
||
thead th.sticky{position:sticky;z-index:11;background:var(--th-bg)}
|
||
thead th.sticky-l{left:0;border-right:1px solid var(--th-border)}
|
||
.sticky-left-offset{position:sticky;z-index:2;background:var(--bg);border-right:1px solid var(--bd);box-shadow:1px 0 4px var(--sh1)}
|
||
thead .sticky-left-offset{background:var(--th-bg);border-bottom:1px solid var(--th-border);border-right:1px solid var(--th-border)}
|
||
tbody td{padding:9px 10px;border-bottom:1px solid var(--bd2);color:var(--fg2);text-align:center;font-size:0.82rem}
|
||
tbody tr:hover{background:rgba(127,119,221,0.04)}
|
||
tbody tr.re{background:rgba(127,119,221,0.015)}
|
||
tbody td.sticky{position:sticky;z-index:2;background:var(--bg)}
|
||
tbody td.sticky-l{left:0;border-right:1px solid var(--bd);box-shadow:1px 0 4px var(--sh1)}
|
||
tbody tr:hover td.sticky{background:var(--bg2)}
|
||
|
||
.tw{position:relative;margin:0 20px 10px;border:1px solid var(--bd2);border-radius:10px;overflow:hidden;background:var(--bg2)}
|
||
.tw::after{content:'';position:absolute;top:0;right:0;bottom:0;width:50px;pointer-events:none;z-index:4;background:linear-gradient(to right,transparent,var(--bg2) 80%)}
|
||
.grp-chip{display:inline-flex;align-items:center;gap:4px;padding:3px 8px 3px 10px;border-radius:6px;border:1px solid var(--bd);background:rgba(127,119,221,0.06);font-size:0.75rem;cursor:pointer;transition:.15s;user-select:none}
|
||
.grp-chip:hover{border-color:var(--hl)}
|
||
.grp-chip .ck{width:12px;height:12px;border-radius:3px;border:1.5px solid var(--fg4);display:flex;align-items:center;justify-content:center;font-size:0.5rem;transition:.15s;flex-shrink:0}
|
||
.grp-chip.active .ck{background:var(--hl);border-color:var(--hl)}
|
||
.grp-chip.active .ck::after{content:'✓';color:#fff;font-weight:700}
|
||
.grp-chip .rm{width:16px;height:16px;border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:0.65rem;opacity:0.4;transition:.15s;margin-left:2px}
|
||
.grp-chip .rm:hover{opacity:1;background:rgba(212,83,126,0.15);color:#ED93B1}
|
||
.grp-chip.inactive{opacity:0.5;border-style:dashed}
|
||
.grp-menu-item{padding:6px 12px;border-radius:4px;cursor:pointer;font-size:0.78rem;color:var(--fg3);transition:.1s;white-space:nowrap}
|
||
.grp-menu-item:hover{background:rgba(127,119,221,0.08);color:var(--fg)}
|
||
|
||
/* collapsed column hint */
|
||
.col-collapsed{width:28px!important;min-width:28px!important;max-width:28px!important;overflow:hidden;padding:0!important;font-size:0.6rem;color:var(--fg4);cursor:pointer;user-select:none;border-bottom:1px solid var(--bd2)}
|
||
.col-collapsed:hover{background:rgba(127,119,221,0.05)}
|
||
.tag{display:inline-block;padding:2px 8px;border-radius:50px;font-size:0.65rem;font-weight:500}
|
||
.tg-green{background:rgba(29,158,117,0.12);color:#5DCAA5}
|
||
.tg-red{background:rgba(212,83,126,0.12);color:#ED93B1}
|
||
.tg-amber{background:rgba(212,168,83,0.12);color:#EFC87E}
|
||
.tg-blue{background:rgba(55,138,221,0.12);color:#85B7EB}
|
||
.tg-gray{background:rgba(136,135,128,0.12);color:#B4B2A9}
|
||
|
||
/* ===== Group rows ===== */
|
||
tr.gr{background:rgba(127,119,221,0.04);cursor:pointer}
|
||
tr.gr:hover{background:rgba(127,119,221,0.08)}
|
||
tr.gr td{padding:7px 12px;font-weight:500;color:var(--hl2);font-size:0.76rem;text-align:left!important}
|
||
tr.gr .gt{display:inline-block;width:16px;text-align:center;margin-right:6px;font-size:0.65rem;opacity:0.5}
|
||
tr.gr2{background:rgba(127,119,221,0.025);cursor:pointer}
|
||
tr.gr2:hover{background:rgba(127,119,221,0.06)}
|
||
tr.gr2 td{padding:6px 12px;font-weight:400;color:var(--fg3);font-size:0.74rem;text-align:left!important}
|
||
tr.gr2 .gt{display:inline-block;width:16px;text-align:center;margin-right:6px;font-size:0.6rem;opacity:0.4;margin-left:20px}
|
||
tr.gr3{background:rgba(127,119,221,0.015);cursor:pointer}
|
||
tr.gr3:hover{background:rgba(127,119,221,0.04)}
|
||
tr.gr3 td{padding:5px 12px;font-weight:400;color:var(--fg4);font-size:0.72rem;text-align:left!important}
|
||
tr.gr3 .gt{display:inline-block;width:16px;text-align:center;margin-right:6px;font-size:0.55rem;opacity:0.3;margin-left:40px}
|
||
@keyframes fadeRow{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}
|
||
tbody tr.data-row{animation:fadeRow .2s ease-out}
|
||
tr.ch.h{display:none}
|
||
.no-result{display:none;text-align:center;padding:40px 20px;color:var(--fg4)}
|
||
.no-result.show{display:block}
|
||
.ft{display:flex;justify-content:space-between;align-items:center;padding:0 20px 16px;flex-wrap:wrap;gap:6px}
|
||
.ft-info{color:var(--fg4);font-size:0.75rem}
|
||
|
||
@media(max-width:768px){
|
||
.sidebar{position:fixed;top:0;right:0;z-index:100;box-shadow:-4px 0 24px rgba(0,0,0,0.4)}
|
||
.sidebar.open{width:100%}
|
||
.summary{grid-template-columns:repeat(2,1fr);padding:8px 12px 0}
|
||
.report-desc{padding:4px 12px 6px}
|
||
.flt{padding:0 12px 8px}
|
||
.flt-g{flex:1;min-width:calc(50% - 6px)}
|
||
.tb{padding:0 12px 6px}
|
||
.tw{margin:0 12px 8px}
|
||
.header{padding:12px 12px 8px}
|
||
.ft{padding:0 12px 14px}
|
||
.header h1{font-size:1rem}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="app">
|
||
|
||
<div class="main">
|
||
<div class="header">
|
||
<div class="header-top">
|
||
<div>
|
||
<h1 id="rptTitle">销售业绩总览</h1>
|
||
<div class="sub" id="rptSub">2026年5月 · 28个字段 · 30条记录 · 测试冻结用</div>
|
||
</div>
|
||
<div class="header-right" style="display:flex;gap:8px;align-items:center">
|
||
<button class="theme-btn" onclick="togTheme()" id="themeBtn" title="切换主题">🌓</button>
|
||
<span id="rptTime"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="summary" id="sumArea"></div>
|
||
<div class="report-desc" id="descArea">📊 加载中...</div>
|
||
|
||
|
||
<div class="flt" id="fltArea">
|
||
<div class="flt-g wide"><label>🔍</label><input id="fG" placeholder="全局搜索..." oninput="rf()"></div>
|
||
</div>
|
||
<div class="flt" id="grpRow">
|
||
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;flex:1">
|
||
<span style="font-size:0.72rem;color:var(--fg4);white-space:nowrap">分组</span>
|
||
<div id="grpChips" style="display:flex;gap:4px;flex-wrap:wrap;align-items:center"></div>
|
||
<div style="position:relative">
|
||
<button class="btn" onclick="showGrpMenu()" style="padding:3px 10px;font-size:0.75rem">+ 添加</button>
|
||
<div id="grpMenu" style="display:none;position:absolute;top:100%;left:0;z-index:50;background:var(--bg2);border:1px solid var(--bd);border-radius:8px;padding:6px;min-width:140px;box-shadow:0 8px 24px rgba(0,0,0,0.3);margin-top:4px"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tb">
|
||
<div class="tb-l">
|
||
<button class="btn" onclick="togSide()" id="btnCfg">⚙️ 列配置</button>
|
||
<span class="cnt" id="recCnt"></span>
|
||
</div>
|
||
<div class="tb-r" id="tbRight" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||
<div id="chartCtrls" style="display:none;gap:6px;align-items:center;flex-wrap:wrap">
|
||
<select id="qcDimSel" onchange="updateQuickChart()" title="按此字段分组" style="padding:4px 8px;background:var(--bg2);border:1px solid var(--bd);border-radius:6px;color:var(--fg);font-size:0.75rem;cursor:pointer;font-family:inherit;outline:none">
|
||
<option value="">维度</option>
|
||
</select>
|
||
<select id="qcValSel" onchange="updateQuickChart()" title="统计此字段的值" style="padding:4px 8px;background:var(--bg2);border:1px solid var(--bd);border-radius:6px;color:var(--fg);font-size:0.75rem;cursor:pointer;font-family:inherit;outline:none">
|
||
<option value="">指标</option>
|
||
<option value="__count__">计数</option>
|
||
</select>
|
||
<div class="chart-type-btns" style="display:flex;gap:3px">
|
||
<button class="btn chart-type-btn" onclick="setChartType('bar')" id="btnBar" title="柱状图">📊</button>
|
||
<button class="btn chart-type-btn" onclick="setChartType('pie')" id="btnPie" title="饼图">🥧</button>
|
||
<button class="btn chart-type-btn" onclick="setChartType('doughnut')" id="btnDoughnut" title="环形图">🍩</button>
|
||
<button class="btn chart-type-btn" onclick="setChartType('line')" id="btnLine" title="折线图">📈</button>
|
||
<button class="btn chart-type-btn act" id="btnList" title="列表视图" onclick="showTableView()" style="font-size:0.7rem">📋</button>
|
||
</div>
|
||
</div>
|
||
<button class="btn-excel" onclick="expXls()">⬇ 导出 Excel</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tw" id="tw"><div class="ts" id="ts">
|
||
<table><thead id="thd"></thead><tbody id="tbd"></tbody></table>
|
||
</div><div class="no-result" id="nr"><p>没有匹配的数据</p></div></div>
|
||
<div class="charts-wrap" id="chartsArea"></div>
|
||
|
||
<div class="ft">
|
||
<div class="ft-info" id="ftInfo"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="sidebar" id="sb">
|
||
<div class="sidebar-inner">
|
||
<div style="margin-bottom:14px;padding-bottom:12px;border-bottom:1px solid var(--bd)">
|
||
<div style="font-size:0.8rem;font-weight:600;margin-bottom:8px">数据源</div>
|
||
<div style="display:flex;gap:6px">
|
||
<button class="btn" onclick="openDataModal()" style="flex:1;justify-content:center;padding:6px">📋 查看/编辑</button>
|
||
</div>
|
||
</div>
|
||
<div class="sb-title">列配置
|
||
<span onclick="togSide()" style="float:right;cursor:pointer;color:var(--fg4);font-size:0.8rem">✕ 关闭</span>
|
||
</div>
|
||
<div class="sb-sec">
|
||
<div class="sb-sec-title" style="display:flex;justify-content:space-between;align-items:center">
|
||
<span>显示列(<span id="visColCnt">0</span>个)</span>
|
||
<span id="showAllToggle" style="font-size:0.72rem;cursor:pointer;color:var(--hl);padding:2px 8px;border-radius:4px;border:1px solid var(--hl)" onclick="togAllCols()">全部展开</span>
|
||
</div>
|
||
<div id="colCheckList"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- ===== 数据源编辑弹窗 ===== -->
|
||
<div id="dataModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;z-index:999;background:rgba(0,0,0,0.5);justify-content:center;align-items:flex-start;padding:40px 20px">
|
||
<div style="background:var(--bg2);border:1px solid var(--bd);border-radius:12px;width:100%;max-width:900px;max-height:85vh;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.3)">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;padding:14px 18px;border-bottom:1px solid var(--bd);flex-shrink:0">
|
||
<span style="font-size:0.9rem;font-weight:600">数据源配置</span>
|
||
<span onclick="closeDataModal()" style="cursor:pointer;color:var(--fg4);font-size:0.85rem">✕ 关闭</span>
|
||
</div>
|
||
<div style="padding:8px 18px;font-size:0.72rem;color:var(--fg4);border-bottom:1px solid var(--bd2);flex-shrink:0">
|
||
修改后点击保存,报表将重新渲染。配置结构:title / subtitle / columns / data / cards / groupBy / colorRules / filters
|
||
</div>
|
||
<textarea id="dataEditor" style="flex:1;padding:14px 18px;font-family:'Consolas','Fira Code',monospace;font-size:0.78rem;background:var(--bg4);color:var(--fg);border:none;outline:none;resize:none;tab-size:2;min-height:300px"></textarea>
|
||
<div style="display:flex;justify-content:flex-end;gap:8px;padding:12px 18px;border-top:1px solid var(--bd);flex-shrink:0">
|
||
<button class="btn" onclick="closeDataModal()">取消</button>
|
||
<button class="btn-excel" onclick="saveDataConfig()">💾 保存并刷新</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const REPORT_CONFIG = {{REPORT_CONFIG_JSON}};
|
||
const DATA_URL = '{{DATA_URL}}';
|
||
|
||
// ===== 从配置初始化 =====
|
||
const COLS=REPORT_CONFIG.columns;
|
||
const D=REPORT_CONFIG.data;
|
||
let data=D.map(r=>({...r}));
|
||
let colVis=COLS.map(()=>true);
|
||
let sCol=6,sAsc=false;
|
||
let grpFields=REPORT_CONFIG.groupBy.map(g=>({...g,active:true}));
|
||
let grpExp={};
|
||
let fzCount=0;
|
||
let dark=true;
|
||
let qcState={dim:'',val:'',type:'bar'};
|
||
let chartVisible=false; // false=table view, true=chart view
|
||
|
||
function showChartView(){
|
||
chartVisible=true;
|
||
$('chartsArea').style.display='';
|
||
$('tw').style.display='none';
|
||
$('nr').style.display='none';
|
||
updateChartTypeBtns();
|
||
renderCharts();
|
||
}
|
||
|
||
function showTableView(){
|
||
chartVisible=false;
|
||
$('chartsArea').style.display='none';
|
||
$('tw').style.display='';
|
||
// sync active button state
|
||
['btnBar','btnPie','btnDoughnut','btnLine','btnList'].forEach(id=>{const b=$(id);if(b)b.classList.remove('act')});
|
||
$('btnList').classList.add('act');
|
||
}
|
||
|
||
function init(){
|
||
$('rptTitle').textContent = REPORT_CONFIG.title || '报表';
|
||
$('rptSub').textContent = REPORT_CONFIG.subtitle || '';
|
||
buildFilters();
|
||
initGrpExp();
|
||
buildColCheck();
|
||
buildGrpChips();
|
||
initChartCtrls();
|
||
buildChartsUI();
|
||
r();
|
||
showTableView(); // default: table view
|
||
}
|
||
|
||
// Load Chart.js dynamically, then init — no race conditions
|
||
function loadChartJS(onReady){
|
||
if(typeof Chart!=='undefined'){onReady();return}
|
||
var s=document.createElement('script');
|
||
s.src='https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js';
|
||
s.onload=onReady;
|
||
s.onerror=function(){
|
||
var s2=document.createElement('script');
|
||
s2.src='https://cdn.bootcdn.net/ajax/libs/Chart.js/4.4.7/chart.umd.min.js';
|
||
s2.onload=onReady;
|
||
s2.onerror=function(){console.warn('Chart.js failed to load from both CDNs, running without charts');onReady()};
|
||
document.head.appendChild(s2);
|
||
};
|
||
document.head.appendChild(s);
|
||
}
|
||
loadChartJS(init);
|
||
|
||
function r(){
|
||
const fd=flt();
|
||
const activeGrps=grpFields.filter(g=>g.active);
|
||
const dd=activeGrps.length>0?grpData(fd,activeGrps):fd;
|
||
renderT(dd,fd);
|
||
renderSum(fd);
|
||
renderCharts();
|
||
}
|
||
function $(id){return document.getElementById(id)}
|
||
function rf(){r()}
|
||
|
||
// ===== Charts =====
|
||
let chartInstances=[];
|
||
const CHART_COLORS=['#7F77DD','#5DCAA5','#F39C12','#E74C3C','#3498DB','#9B59B6','#1ABC9C','#E67E22','#2ECC71','#E91E63'];
|
||
const CHART_COLORS_DARK=CHART_COLORS;
|
||
const CHART_COLORS_LIGHT=['#6B63C9','#1D9E75','#D68910','#C0392B','#2980B9','#8E44AD','#17A589','#D35400','#27AE60','#C2185B'];
|
||
function chartColors(){return dark?CHART_COLORS_DARK:CHART_COLORS_LIGHT}
|
||
function chartGridColor(){return dark?'rgba(255,255,255,0.04)':'rgba(0,0,0,0.05)'}
|
||
function chartTextColor(){return dark?'rgba(232,230,227,0.5)':'rgba(42,40,38,0.5)'}
|
||
|
||
function initChartCtrls(){
|
||
const charts=REPORT_CONFIG.charts||[];
|
||
const ctrls=$('chartCtrls');
|
||
if(charts.length){if(ctrls)ctrls.style.display='none';return}
|
||
const numCols=COLS.filter(c=>{
|
||
const vals=data.map(r=>parseFloat(r[c.k]));
|
||
return vals.some(v=>!isNaN(v));
|
||
});
|
||
if(!numCols.length){if(ctrls)ctrls.style.display='none';return}
|
||
if(!ctrls)return;
|
||
ctrls.style.display='flex';
|
||
const dimSel=$('qcDimSel');
|
||
const valSel=$('qcValSel');
|
||
// Auto-select defaults: pick best categorical dim (string, few unique vals, not first col)
|
||
if(!qcState.dim){
|
||
const catCols=COLS.filter((c,i)=>{
|
||
if(i===0)return false; // skip ID column
|
||
const vals=data.map(r=>String(r[c.k]||''));
|
||
const uniq=new Set(vals);
|
||
return uniq.size>1&&uniq.size<=15;
|
||
});
|
||
qcState.dim=catCols.length?catCols[0].k:(COLS[2]?COLS[2].k:COLS[0].k);
|
||
}
|
||
if(!qcState.val){qcState.val='__count__'}
|
||
if(dimSel){
|
||
dimSel.innerHTML='<option value="">维度</option>'+COLS.map(c=>`<option value="${c.k}">${c.l}</option>`).join('');
|
||
dimSel.value=qcState.dim;
|
||
}
|
||
if(valSel){
|
||
valSel.innerHTML='<option value="">指标</option><option value="__count__">计数</option>'+numCols.map(c=>`<option value="${c.k}">${c.l}</option>`).join('');
|
||
valSel.value=qcState.val;
|
||
}
|
||
updateChartTypeBtns();
|
||
}
|
||
|
||
function updateChartTypeBtns(){
|
||
['bar','pie','doughnut','line'].forEach(t=>{
|
||
const btn=$(`btn${t==='bar'?'Bar':t==='pie'?'Pie':t==='doughnut'?'Doughnut':'Line'}`);
|
||
if(btn)btn.classList.toggle('act',qcState.type===t);
|
||
});
|
||
}
|
||
|
||
function setChartType(type){
|
||
qcState.type=type;
|
||
showChartView();
|
||
}
|
||
|
||
function updateQuickChart(){
|
||
qcState.dim=$('qcDimSel')?$('qcDimSel').value:'';
|
||
qcState.val=$('qcValSel')?$('qcValSel').value:'';
|
||
renderCharts();
|
||
}
|
||
|
||
function buildChartsUI(){
|
||
const charts=REPORT_CONFIG.charts||[];
|
||
if(!charts.length){
|
||
// Quick mode: just prepare an empty container; controls are in tb-r
|
||
$('chartsArea').innerHTML='<div id="qcChartWrap" style="grid-column:1/-1;display:none"></div>';
|
||
}else{
|
||
let html='';
|
||
charts.forEach((ch,i)=>{
|
||
html+=`<div class="chart-box"><div class="chart-title">${ch.title||'图表 '+ (i+1)}</div><canvas id="chart${i}" height="200"></canvas></div>`;
|
||
});
|
||
$('chartsArea').innerHTML=html;
|
||
}
|
||
}
|
||
|
||
function renderCharts(){
|
||
chartInstances.forEach(c=>c.destroy());
|
||
chartInstances=[];
|
||
const fd=flt();
|
||
const charts=REPORT_CONFIG.charts||[];
|
||
if(charts.length){
|
||
charts.forEach((ch,i)=>{
|
||
const el=document.getElementById('chart'+i);
|
||
if(!el)return;
|
||
const lf=ch.labelField, vf=ch.valueField;
|
||
const groups={};
|
||
fd.forEach(r=>{
|
||
const k=String(r[lf]||'(空)');
|
||
if(!groups[k])groups[k]=0;
|
||
if(vf&&ch.calc==='sum')groups[k]+=Number(r[vf])||0;
|
||
else if(vf&&ch.calc==='avg'){
|
||
if(!groups['_cnt_'+k]){groups['_cnt_'+k]=0;groups[k]=0}
|
||
groups[k]+=Number(r[vf])||0;
|
||
groups['_cnt_'+k]+=1;
|
||
}else groups[k]+=1;
|
||
});
|
||
if(vf&&ch.calc==='avg'){
|
||
Object.keys(groups).forEach(k=>{
|
||
if(k.startsWith('_cnt_'))return;
|
||
groups[k]=groups['_cnt_'+k]?groups[k]/groups['_cnt_'+k]:0;
|
||
});
|
||
Object.keys(groups).forEach(k=>{if(k.startsWith('_cnt_'))delete groups[k]});
|
||
}
|
||
const labels=Object.keys(groups).sort();
|
||
const values=labels.map(l=>groups[l]);
|
||
const isPie=ch.type==='pie'||ch.type==='doughnut'||ch.type==='polarArea';
|
||
const cfg={
|
||
type:ch.type,
|
||
data:{
|
||
labels,
|
||
datasets:[{
|
||
label:ch.title||(ch.calc==='count'?'计数':'数值'),
|
||
data:values,
|
||
backgroundColor:labels.map((_,j)=>chartColors()[j%chartColors().length]),
|
||
borderColor:isPie?'transparent':chartColors(),
|
||
borderWidth:isPie?0:1,
|
||
borderRadius:isPie?0:4,
|
||
}]
|
||
},
|
||
options:{
|
||
responsive:true,
|
||
maintainAspectRatio:false,
|
||
plugins:{
|
||
legend:isPie?{position:'right',labels:{color:chartTextColor(),padding:12,font:{size:11}}}:{display:false}
|
||
},
|
||
scales:isPie?{}:{x:{grid:{color:chartGridColor()},ticks:{color:chartTextColor(),font:{size:10}}},y:{grid:{color:chartGridColor()},ticks:{color:chartTextColor(),font:{size:10}}}}
|
||
}
|
||
};
|
||
chartInstances.push(new Chart(el,cfg));
|
||
});
|
||
}else{
|
||
// Quick chart mode
|
||
const dim=qcState.dim, val=qcState.val, tp=qcState.type;
|
||
if(!dim||!val)return;
|
||
if(typeof Chart==='undefined'){console.warn('Chart.js not loaded');return}
|
||
const groups={};
|
||
fd.forEach(r=>{
|
||
const k=String(r[dim]||'(空)');
|
||
if(!groups[k])groups[k]=0;
|
||
groups[k]+=val==='__count__'?1:(Number(r[val])||0);
|
||
});
|
||
const labels=Object.keys(groups).sort();
|
||
const values=labels.map(l=>groups[l]);
|
||
const wrap=$('qcChartWrap');
|
||
if(!wrap)return;
|
||
wrap.style.display='block';
|
||
wrap.innerHTML='<div class="chart-box"><div class="chart-title">'+(COLS.find(c=>c.k===dim)?.l||dim)+' × '+(val==='__count__'?'计数':COLS.find(c=>c.k===val)?.l||val)+'</div><div style="position:relative;height:260px"><canvas id="qcCanvas"></canvas></div></div>';
|
||
const canvas=$('qcCanvas');
|
||
if(!canvas)return;
|
||
const isPie=tp==='pie'||tp==='doughnut';
|
||
try{
|
||
const cfg={
|
||
type:tp,
|
||
data:{
|
||
labels,
|
||
datasets:[{
|
||
label:val==='__count__'?'计数':(COLS.find(c=>c.k===val)?.l||val),
|
||
data:values,
|
||
backgroundColor:labels.map((_,j)=>chartColors()[j%chartColors().length]),
|
||
borderColor:isPie?'transparent':chartColors(),
|
||
borderWidth:isPie?0:1,
|
||
borderRadius:isPie?0:4,
|
||
}]
|
||
},
|
||
options:{
|
||
responsive:true,
|
||
maintainAspectRatio:false,
|
||
plugins:{
|
||
legend:isPie?{position:'right',labels:{color:chartTextColor(),padding:12,font:{size:11}}}:{display:false}
|
||
},
|
||
scales:isPie?{}:{x:{grid:{color:chartGridColor()},ticks:{color:chartTextColor(),font:{size:10}}},y:{grid:{color:chartGridColor()},ticks:{color:chartTextColor(),font:{size:10}}}}
|
||
}
|
||
};
|
||
chartInstances.push(new Chart(canvas,cfg));
|
||
}catch(e){console.error('Chart render error:',e)}
|
||
}
|
||
}
|
||
|
||
function buildFilters(){
|
||
const fltArea=$('fltArea');
|
||
if(!fltArea)return;
|
||
const filters=REPORT_CONFIG.filters||[];
|
||
let html='<div class="flt-g wide"><label>🔍</label><input id="fG" placeholder="全局搜索..." oninput="rf()"></div>';
|
||
filters.forEach((f,i)=>{
|
||
const lbl=f.label||COLS.find(c=>c.k===f.field)?.l||f.field||'';
|
||
if(f.type==='select'){
|
||
const opts=Array.isArray(f.options)?f.options:(f.options||'').split(',').map(o=>o.trim()).filter(Boolean);
|
||
html+=`<div class="flt-g"><label>${lbl}</label><select id="flt_${i}" onchange="rf()"><option value="">全部</option>${opts.map(o=>`<option>${o}</option>`).join('')}</select></div>`;
|
||
}else{
|
||
html+=`<div class="flt-g"><label>${lbl}</label><input id="flt_${i}" placeholder="全部" oninput="rf()"></div>`;
|
||
}
|
||
});
|
||
if(filters.length>0){
|
||
html+='<div style="display:flex;gap:4px;align-items:center"><button class="btn" onclick="document.querySelectorAll(\'.flt-g input,.flt-g select\').forEach(e=>e.value=\'\');rf()">清空</button></div>';
|
||
}
|
||
fltArea.innerHTML=html;
|
||
}
|
||
|
||
function flt(){
|
||
const g=$('fG')?$('fG').value.trim().toLowerCase():'';
|
||
const filters=REPORT_CONFIG.filters||[];
|
||
return data.filter(r=>{
|
||
for(let i=0;i<filters.length;i++){
|
||
const f=filters[i];
|
||
const el=document.getElementById('flt_'+i);
|
||
if(!el||!el.value)continue;
|
||
const v=String(r[f.field]||'').toLowerCase();
|
||
if(!v.includes(el.value.toLowerCase()))return false;
|
||
}
|
||
if(g&&!Object.values(r).some(v=>String(v).toLowerCase().includes(g)))return false;
|
||
return true;
|
||
});
|
||
}
|
||
|
||
function renderT(dd,fd){
|
||
const th=$('thd'),tb=$('tbd'),nr=$('nr');
|
||
const groupHideKeys=grpFields.filter(g=>g.active).map(g=>g.key);
|
||
th.innerHTML='<tr><th class="sticky sticky-l" style="width:50px;text-align:center;font-size:0.68rem;color:var(--fg4);background:var(--th-bg);border-bottom:1px solid var(--th-border);cursor:default">#</th>'+COLS.map((c,i)=>{
|
||
let cls='';if(i===sCol)cls=sAsc?'sa-up':'sa-dn';
|
||
// Hide grouped field columns from header too, to match body column count
|
||
if(!colVis[i]||groupHideKeys.includes(c.k))return '';
|
||
let st='',stLeft='0px';
|
||
if(i<fzCount&&!groupHideKeys.includes(c.k)){
|
||
let calcLeft=50;
|
||
for(let j=0;j<i;j++){
|
||
if(colVis[j]&&j<fzCount&&!groupHideKeys.includes(COLS[j].k)){
|
||
calcLeft+=COLS[j].w;
|
||
}
|
||
}
|
||
st='sticky-left-offset';
|
||
stLeft=calcLeft+'px';
|
||
}
|
||
let pin=fzCount>i?'on':'';
|
||
return `<th class="${st} ${cls}" style="width:${c.w||90}px${st!=''?`;left:${stLeft}`:''}" onclick="sortBy(${i})">${c.l}<span class="sa"></span><span class="fz-pin ${pin}" onclick="event.stopPropagation();fzPin(${i})" title="冻结到此列">📌</span></th>`;
|
||
}).join('')+'</tr>';
|
||
|
||
if(!dd.length){nr.classList.add('show');tb.innerHTML=''}else{
|
||
nr.classList.remove('show');
|
||
tb.innerHTML=dd.map((r,i)=>{
|
||
// Group row
|
||
if(r._isGroup){
|
||
if(isAnyAncestorCollapsed(r._ancestors,grpExp))return '';
|
||
const ex=grpExp[r._key]!==false;
|
||
if(ex && grpExp[r._key]===undefined)grpExp[r._key]=true;
|
||
const cls=r._level===0?'gr':r._level===1?'gr2':'gr3';
|
||
const cntText=r._count>5?`${r._count}条`:'';
|
||
const indent=r._level>0?' '.repeat(r._level*4):'';
|
||
const groupHideKeys=grpFields.filter(g=>g.active).map(g=>g.key);
|
||
// Build individual tds so sums appear under the right columns
|
||
let firstVisIdx=-1;
|
||
for(let vi=0;vi<COLS.length;vi++){if(colVis[vi]&&!groupHideKeys.includes(COLS[vi].k)){firstVisIdx=vi;break;}}
|
||
let cells='<td style="width:50px;text-align:center;color:var(--fg4);font-size:0.7rem;background:var(--th-bg);border-right:1px solid var(--th-border);position:sticky;left:0;z-index:3">∑</td>';
|
||
cells+=COLS.map((c,i)=>{
|
||
if(!colVis[i]||groupHideKeys.includes(c.k))return '';
|
||
let st='';let stLeft='0px';
|
||
if(i<fzCount){
|
||
st='sticky-left-offset';
|
||
let calcLeft=50;
|
||
for(let j=0;j<i;j++){
|
||
if(colVis[j]&&j<fzCount&&!groupHideKeys.includes(COLS[j].k))calcLeft+=COLS[j].w;
|
||
}
|
||
stLeft=calcLeft+'px';
|
||
}
|
||
if(i===firstVisIdx&&firstVisIdx>=0){
|
||
const grpLeft=fzCount>0?stLeft:'50px';
|
||
const grpCls=fzCount>0?st:'sticky-left-offset';
|
||
return `<td class="${grpCls}" style="width:${c.w||90}px;left:${grpLeft};text-align:left;font-weight:500;color:var(--hl2)"><span class="gt">${ex?'▼':'▶'}</span>${indent}${r._key}${cntText?' <span style="color:var(--fg4);font-weight:400">'+cntText+'</span>':''}</td>`;
|
||
}
|
||
return `<td class="${st}" style="width:${c.w||90}px;${st!=''?`left:${stLeft}`:''}"></td>`;
|
||
}).join('');
|
||
const ancestorsAttr=r._ancestors&&r._ancestors.length?` data-ancestors="${r._ancestors.join(',')}"`:'';
|
||
return `<tr class="${cls} data-row" data-group="${r._key}"${ancestorsAttr} onclick="toggleGroup('${r._key}')">${cells}</tr>`;
|
||
}
|
||
// Check collapse ancestors
|
||
if(r._ancestors&&isAnyAncestorCollapsed(r._ancestors,grpExp))return '';
|
||
// Regular data row
|
||
const odd=(i%2)?'re':'';
|
||
// Get active group field keys to auto-hide
|
||
const groupHideKeys=grpFields.filter(g=>g.active).map(g=>g.key);
|
||
// Row number - restart within each group
|
||
const rowNum=dd.filter((x,j)=>j<=i && !x._isGroup && !(x._ancestors&&isAnyAncestorCollapsed(x._ancestors,grpExp))).length;
|
||
const ancestorsAttr=r._ancestors&&r._ancestors.length?` data-ancestors="${r._ancestors.join(',')}"`:'';
|
||
return `<tr class="${odd} data-row"${ancestorsAttr}><td style="width:50px;text-align:center;color:var(--fg4);font-size:0.72rem;background:var(--bg);border-right:1px solid var(--bd);position:sticky;left:0;z-index:3">${rowNum}</td>${COLS.map((c,j)=>{
|
||
if(!colVis[j]||groupHideKeys.includes(c.k))return '';
|
||
let v=r[c.k];
|
||
let st='',stLeft='0px';
|
||
if(j<fzCount){
|
||
st='sticky-left-offset';
|
||
let calcLeft=50;
|
||
for(let k=0;k<j;k++){
|
||
if(colVis[k]&&k<fzCount&&!groupHideKeys.includes(COLS[k].k))calcLeft+=COLS[k].w;
|
||
}
|
||
stLeft=calcLeft+'px';
|
||
}
|
||
if(st.includes('sticky-l'))st='sticky-left-offset';
|
||
// 应用颜色规则
|
||
const cc=getCellColor(c.k, v);
|
||
let h=cc?`<span class="tag" style="background:${cc}22;color:${cc}">${v}</span>`:v;
|
||
return `<td class="${st}" style="width:${c.w||90}px;${st!=''?`left:${stLeft}`:''}">${h}</td>`;
|
||
}).join('')}</tr>`;
|
||
}).filter(Boolean).join('');
|
||
}
|
||
$('recCnt').textContent=`${data.length}条/${fd.length}条`;
|
||
// Set min-width = total width of all visible columns + # column
|
||
let totalW=50; // # column
|
||
COLS.forEach((c,i)=>{if(colVis[i]&&!groupHideKeys.includes(c.k))totalW+=c.w||90});
|
||
const tbl=document.querySelector('#ts table');
|
||
if(tbl){
|
||
tbl.style.minWidth=totalW+'px';
|
||
}
|
||
}
|
||
|
||
function renderSum(fd){
|
||
// Conditional cards based on config
|
||
const cards=REPORT_CONFIG.cards;
|
||
if(cards&&cards.length){
|
||
let html='';
|
||
cards.forEach(c=>{
|
||
let val=0;
|
||
if(c.calc==='sum'){val=fd.reduce((s,r)=>s+(Number(r[c.field])||0),0);}
|
||
else if(c.calc==='distinct'){val=[...new Set(fd.map(r=>r[c.field]))].length;}
|
||
else if(c.calc==='count'){val=fd.length;}
|
||
const prefix=c.prefix||'';
|
||
const unit=c.unit||'';
|
||
const display=unit?prefix+(val/(unit==='万'?10000:1)).toFixed(1)+unit:prefix+val.toLocaleString();
|
||
html+=`<div class="card"><div class="l">${c.label}</div><div class="v">${display}</div><div class="c">${fd.length}条记录</div></div>`;
|
||
});
|
||
$('sumArea').innerHTML=html;
|
||
}else{
|
||
$('sumArea').innerHTML='';
|
||
}
|
||
$('ftInfo').textContent='';
|
||
$('rptTime').textContent=new Date().toLocaleString('zh-CN');
|
||
const activeGrps=grpFields.filter(g=>g.active);
|
||
let groupInfo=activeGrps.length>0?' · 分组:'+activeGrps.map(g=>g.label).join(' → '):'';
|
||
const now=new Date();
|
||
$('descArea').innerHTML=REPORT_CONFIG.description
|
||
+ (groupInfo?`<span>📂${groupInfo}</span>`:'')
|
||
+ `<span style="color:var(--fg4)">🕑 ${now.toLocaleDateString('zh-CN')} ${now.toLocaleTimeString('zh-CN')}</span>`;
|
||
}
|
||
|
||
// ===== Chip-based Grouping =====
|
||
function initGrpExp(){
|
||
grpExp={};
|
||
const act=grpFields.filter(g=>g.active);
|
||
if(!act.length)return;
|
||
const first=act[0];
|
||
[...new Set(data.map(r=>r[first.key]))].forEach(v=>grpExp[v]=true);
|
||
}
|
||
function buildGrpChips(){
|
||
const el=$('grpChips');
|
||
el.innerHTML=grpFields.map((g,i)=>{
|
||
const cls=g.active?'active':'inactive';
|
||
const lbl=COLS.find(c=>c.k===g.key)?.l||g.key;
|
||
return `<span class="grp-chip ${cls}" onclick="toggleGrpField(${i})" title="点击切换开关">`+
|
||
`<span class="ck"></span>${lbl}`+
|
||
`<span class="rm" onclick="event.stopPropagation();removeGrpField(${i})" title="移除">✕</span></span>`;
|
||
}).join('');
|
||
}
|
||
function showGrpMenu(){
|
||
const menu=$('grpMenu');
|
||
if(menu.style.display!=='block'){
|
||
const used=grpFields.map(g=>g.key);
|
||
const avail=COLS.filter(c=>!used.includes(c.k));
|
||
menu.innerHTML=avail.length?avail.map(c=>
|
||
`<div class="grp-menu-item" onclick="addGrpField('${c.k}')">+ ${c.l}</div>`
|
||
).join(''):'<div style="padding:6px 12px;font-size:0.75rem;color:var(--fg4)">已全部添加</div>';
|
||
menu.style.display='block';
|
||
}else{
|
||
menu.style.display='none';
|
||
}
|
||
}
|
||
document.addEventListener('click',function(e){
|
||
const menu=$('grpMenu');
|
||
if(menu&&!e.target.closest('#grpRow'))menu.style.display='none';
|
||
});
|
||
function addGrpField(key){
|
||
const label=COLS.find(c=>c.k===key)?.l||key;
|
||
grpFields.push({key,label,active:true});
|
||
grpExp={};
|
||
initGrpExp();
|
||
buildGrpChips();
|
||
$('grpMenu').style.display='none';
|
||
r();
|
||
}
|
||
function toggleGrpField(idx){
|
||
grpFields[idx].active=!grpFields[idx].active;
|
||
grpExp={};
|
||
initGrpExp();
|
||
buildGrpChips();
|
||
r();
|
||
}
|
||
function removeGrpField(idx){
|
||
grpFields.splice(idx,1);
|
||
grpExp={};
|
||
initGrpExp();
|
||
buildGrpChips();
|
||
r();
|
||
}
|
||
|
||
// ===== 颜色规则匹配 =====
|
||
function getCellColor(fieldKey, val){
|
||
if(!val&&val!==0)return null;
|
||
const rules=REPORT_CONFIG.colorRules||[];
|
||
for(const cr of rules){
|
||
if(cr.field!==fieldKey)continue;
|
||
for(const r of cr.rules||[]){
|
||
const v=String(val);
|
||
const cv=r.value||'';
|
||
switch(r.op){
|
||
case 'eq': if(v===cv)return r.color;break;
|
||
case 'neq': if(v!==cv)return r.color;break;
|
||
case '>': if(parseFloat(v)>parseFloat(cv))return r.color;break;
|
||
case '<': if(parseFloat(v)<parseFloat(cv))return r.color;break;
|
||
case '>=': if(parseFloat(v)>=parseFloat(cv))return r.color;break;
|
||
case '<=': if(parseFloat(v)<=parseFloat(cv))return r.color;break;
|
||
case 'contains': if(v.includes(cv))return r.color;break;
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ===== N-level Group Data =====
|
||
function grpData(fd,fields){
|
||
function buildLevel(items,level,fAncestors){
|
||
fAncestors=fAncestors||[];
|
||
// Last level - just show items, exit early to avoid fields[level] access
|
||
if(level>=fields.length||level>=4){
|
||
const out=[];
|
||
items.forEach((r,i)=>out.push({...r,_data:true,_path:[],_i:i,_ancestors:fAncestors}));
|
||
return out;
|
||
}
|
||
const key=fields[level].key;
|
||
const groups={};
|
||
items.forEach(r=>{const v=r[key];if(!groups[v])groups[v]={items:[]};groups[v].items.push(r)});
|
||
const out=[];
|
||
Object.keys(groups).sort().forEach(k=>{
|
||
const myAncestors=[...fAncestors,k];
|
||
const path=out.length>0&&out[out.length-1]._isGroup?[...(out[out.length-1]._path||[])]:[];
|
||
const myPath=path;
|
||
out.push({_isGroup:true,_key:k,_path:myPath,_level:level,_count:groups[k].items.length,_ancestors:fAncestors});
|
||
const children=buildLevel(groups[k].items,level+1,myAncestors);
|
||
children.forEach(c=>{if(!c._parentKey)c._parentKey=k});
|
||
out.push(...children);
|
||
});
|
||
return out;
|
||
}
|
||
return buildLevel(fd,0,[]);
|
||
}
|
||
function isAnyAncestorCollapsed(ancestors, grpExp){
|
||
if(!ancestors||!ancestors.length)return false;
|
||
return ancestors.some(a=>grpExp[a]===false);
|
||
}
|
||
function toggleGroup(k){
|
||
const key=typeof k==='string'?k:k;
|
||
grpExp[key]=!grpExp[key];
|
||
const expanded=grpExp[key];
|
||
const parentRow=document.querySelector(`tr[data-group="${key}"]`);
|
||
if(parentRow){
|
||
const icon=parentRow.querySelector('.gt');
|
||
if(icon)icon.textContent=expanded?'▼':'▶';
|
||
}
|
||
// Find all descendants via data-ancestors and toggle them
|
||
document.querySelectorAll('tr.data-row').forEach(r=>{
|
||
const a=r.dataset.ancestors;
|
||
if(!a)return;
|
||
const list=a.split(',');
|
||
if(list.includes(key)){
|
||
if(expanded){
|
||
// When expanding, check if any OTHER ancestor is still collapsed
|
||
const stillCollapsed=list.some(a=>a!==key&&grpExp[a]===false);
|
||
r.style.display=stillCollapsed?'none':'';
|
||
}else{
|
||
r.style.display='none';
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// ===== Sort =====
|
||
function sortBy(i){
|
||
if(sCol===i){sAsc=!sAsc}else{sCol=i;sAsc=true}
|
||
const k=COLS[i].k;
|
||
data.sort((a,b)=>{
|
||
const va=a[k],vb=b[k];
|
||
if(typeof va==='number')return sAsc?va-vb:vb-va;
|
||
const na=parseFloat(va),nb=parseFloat(vb);
|
||
if(!isNaN(na)&&!isNaN(nb))return sAsc?na-nb:nb-na;
|
||
return sAsc?String(va).localeCompare(String(vb),'zh'):String(vb).localeCompare(String(va),'zh');
|
||
});
|
||
r();
|
||
}
|
||
|
||
// ===== Freeze =====
|
||
function fzPin(col){
|
||
fzCount=(fzCount===col+1)?0:col+1;
|
||
r();
|
||
}
|
||
function getFrozenLeft(i, groupHideKeys){
|
||
if(i>=fzCount)return '0px';
|
||
let w=50; // # column width
|
||
for(let j=0;j<i;j++){
|
||
if(colVis[j]&&j<fzCount&&!groupHideKeys.includes(COLS[j].k)){
|
||
w+=COLS[j].w;
|
||
}
|
||
}
|
||
return w+'px';
|
||
}
|
||
|
||
// ===== Columns =====
|
||
let dragIdx=-1;
|
||
let savedColVis=null;
|
||
|
||
function buildColCheck(){
|
||
const el=$('colCheckList');
|
||
el.innerHTML=COLS.map((c,i)=>`
|
||
<div class="col-check" draggable="false" data-ci="${i}">
|
||
<span class="drag-h" draggable="true" data-ci="${i}" title="拖拽排序">≡</span>
|
||
<input type="checkbox" ${colVis[i]?'checked':''} onchange="togCol(${i})" id="cc${i}">
|
||
<label for="cc${i}">${c.l}</label><span class="col-tag">${c.k}</span>
|
||
</div>
|
||
`).join('');
|
||
// Update visible count and toggle button
|
||
const visCnt=COLS.filter((_,i)=>colVis[i]).length;
|
||
const cntEl=$('visColCnt');
|
||
if(cntEl)cntEl.textContent=visCnt;
|
||
const togEl=$('showAllToggle');
|
||
if(togEl)togEl.textContent=visCnt===COLS.length?'恢复默认':'全部展开';
|
||
// Attach drag event listeners
|
||
document.querySelectorAll('.drag-h').forEach(h=>{
|
||
h.addEventListener('dragstart',onDragStart);
|
||
h.addEventListener('dragend',onDragEnd);
|
||
});
|
||
document.querySelectorAll('.col-check').forEach(d=>{
|
||
d.addEventListener('dragover',onDragOver);
|
||
d.addEventListener('dragleave',onDragLeave);
|
||
d.addEventListener('drop',onDrop);
|
||
});
|
||
}
|
||
function onDragStart(e){
|
||
dragIdx=parseInt(e.target.dataset.ci);
|
||
e.dataTransfer.effectAllowed='move';
|
||
e.dataTransfer.setData('text/plain',dragIdx);
|
||
setTimeout(()=>e.target.closest('.col-check').classList.add('dragging'),0);
|
||
}
|
||
function onDragEnd(e){
|
||
dragIdx=-1;
|
||
document.querySelectorAll('.col-check').forEach(d=>{
|
||
d.classList.remove('dragging','drag-over-top','drag-over-bottom');
|
||
});
|
||
}
|
||
function onDragOver(e){
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect='move';
|
||
const item=e.target.closest('.col-check');
|
||
if(!item||dragIdx<0)return;
|
||
const targetIdx=parseInt(item.dataset.ci);
|
||
if(targetIdx===dragIdx)return;
|
||
const rect=item.getBoundingClientRect();
|
||
const mid=rect.top+rect.height/2;
|
||
const isAbove=e.clientY<mid;
|
||
document.querySelectorAll('.col-check').forEach(d=>d.classList.remove('drag-over-top','drag-over-bottom'));
|
||
item.classList.add(isAbove?'drag-over-top':'drag-over-bottom');
|
||
}
|
||
function onDragLeave(e){
|
||
const item=e.target.closest('.col-check');
|
||
if(item)item.classList.remove('drag-over-top','drag-over-bottom');
|
||
}
|
||
function onDrop(e){
|
||
e.preventDefault();
|
||
const from=parseInt(e.dataTransfer.getData('text/plain'));
|
||
const target=e.target.closest('.col-check');
|
||
if(!target||from<0)return;
|
||
const to=parseInt(target.dataset.ci);
|
||
if(from===to){onDragEnd(e);return}
|
||
const rect=target.getBoundingClientRect();
|
||
const mid=rect.top+rect.height/2;
|
||
const insertAfter=e.clientY>=mid;
|
||
reorderCol(from,to,insertAfter);
|
||
onDragEnd(e);
|
||
}
|
||
function reorderCol(from,to,insertAfter){
|
||
// Calculate actual target position
|
||
let targetPos=to;
|
||
if(!insertAfter)targetPos=to>from?to-1:to;
|
||
|
||
const col=COLS.splice(from,1)[0];
|
||
const vis=colVis.splice(from,1)[0];
|
||
COLS.splice(targetPos,0,col);
|
||
colVis.splice(targetPos,0,vis);
|
||
|
||
buildColCheck();
|
||
r();
|
||
}
|
||
function togCol(i){
|
||
colVis[i]=!colVis[i];
|
||
document.getElementById('cc'+i).checked=colVis[i];
|
||
savedColVis=null;
|
||
r();
|
||
}
|
||
function togAllCols(){
|
||
const vis=COLS.filter((_,i)=>colVis[i]).length;
|
||
if(vis===COLS.length){
|
||
if(savedColVis)colVis=[...savedColVis];
|
||
else colVis=COLS.map((_,i)=>i<10);
|
||
savedColVis=null;
|
||
}else{
|
||
savedColVis=[...colVis];
|
||
colVis=COLS.map(()=>true);
|
||
}
|
||
buildColCheck();
|
||
r();
|
||
}
|
||
|
||
function togSide(){$('sb').classList.toggle('open')}
|
||
function togTheme(){dark=!dark;document.body.classList.toggle('light',!dark);$('themeBtn').textContent=dark?'🌓':'☀️'}
|
||
|
||
function expXls(){
|
||
const fd=flt();
|
||
const hd=COLS.map(c=>c.l);
|
||
const rows=fd.map(r=>COLS.map(c=>r[c.k]));
|
||
const ws=XLSX.utils.aoa_to_sheet([hd,...rows]);
|
||
const wb=XLSX.utils.book_new();
|
||
XLSX.utils.book_append_sheet(wb,ws,'报表');
|
||
XLSX.writeFile(wb,`报表_${new Date().toISOString().slice(0,10)}.xlsx`);
|
||
}
|
||
|
||
// ===== 数据源弹窗 =====
|
||
function openDataModal(){
|
||
const ed=$('dataEditor');
|
||
ed.value=JSON.stringify(REPORT_CONFIG,null,2);
|
||
$('dataModal').style.display='flex';
|
||
ed.focus();
|
||
}
|
||
function closeDataModal(){
|
||
$('dataModal').style.display='none';
|
||
}
|
||
function saveDataConfig(){
|
||
const ed=$('dataEditor');
|
||
let cfg;
|
||
try{cfg=JSON.parse(ed.value)}catch(e){alert('JSON 格式错误:'+e.message);return}
|
||
// Merge into REPORT_CONFIG
|
||
Object.assign(REPORT_CONFIG,cfg);
|
||
// Re-init from config
|
||
COLS.length=0;COLS.push(...REPORT_CONFIG.columns);
|
||
D.length=0;D.push(...REPORT_CONFIG.data);
|
||
data=D.map(r=>({...r}));
|
||
colVis=COLS.map(()=>true);
|
||
grpFields=REPORT_CONFIG.groupBy.map(g=>({...g,active:true}));
|
||
grpExp={};
|
||
sCol=6;sAsc=false;
|
||
fzCount=0;
|
||
closeDataModal();
|
||
init();
|
||
}
|
||
// Click backdrop to close
|
||
document.addEventListener('click',function(e){
|
||
const m=$('dataModal');
|
||
if(m&&e.target===m)closeDataModal();
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|