Files
gzth/yuthon_ai_agent/static/src/html/report_template.html
T
2026-06-10 20:09:03 +08:00

1026 lines
46 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>&#128269;</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?'&nbsp;'.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>&#128194;${groupInfo}</span>`:'')
+ `<span style="color:var(--fg4)">&#128337; ${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>