|
|
|
@@ -1,270 +1,747 @@
|
|
|
|
|
<!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, user-scalable=no">
|
|
|
|
|
<title>{{REPORT_NAME}}</title>
|
|
|
|
|
<style>
|
|
|
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
|
|
|
body {
|
|
|
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
|
|
|
|
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
|
|
|
|
background: #f0f2f5;
|
|
|
|
|
color: #333;
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
padding-bottom: env(safe-area-inset-bottom);
|
|
|
|
|
}
|
|
|
|
|
<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>
|
|
|
|
|
<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}
|
|
|
|
|
|
|
|
|
|
/* 顶部标题栏 */
|
|
|
|
|
.header {
|
|
|
|
|
background: linear-gradient(135deg, #1a73e8 0%, #4285f4 100%);
|
|
|
|
|
color: white;
|
|
|
|
|
padding: 20px 16px 16px;
|
|
|
|
|
position: sticky;
|
|
|
|
|
top: 0;
|
|
|
|
|
z-index: 100;
|
|
|
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
|
|
|
|
}
|
|
|
|
|
.header h1 {
|
|
|
|
|
font-size: 18px;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
margin-bottom: 6px;
|
|
|
|
|
line-height: 1.4;
|
|
|
|
|
}
|
|
|
|
|
.header .meta {
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
opacity: 0.85;
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
align-items: center;
|
|
|
|
|
}
|
|
|
|
|
: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}
|
|
|
|
|
|
|
|
|
|
/* 工具栏 */
|
|
|
|
|
.toolbar {
|
|
|
|
|
padding: 12px 16px;
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
align-items: center;
|
|
|
|
|
background: white;
|
|
|
|
|
border-bottom: 1px solid #e8e8e8;
|
|
|
|
|
}
|
|
|
|
|
.toolbar .count {
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
color: #666;
|
|
|
|
|
}
|
|
|
|
|
.toolbar .refresh-btn {
|
|
|
|
|
background: #1a73e8;
|
|
|
|
|
color: white;
|
|
|
|
|
border: none;
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
padding: 8px 16px;
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 4px;
|
|
|
|
|
transition: opacity 0.2s;
|
|
|
|
|
}
|
|
|
|
|
.toolbar .refresh-btn:active { opacity: 0.7; }
|
|
|
|
|
.toolbar .refresh-btn.loading { opacity: 0.6; pointer-events: none; }
|
|
|
|
|
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}
|
|
|
|
|
|
|
|
|
|
/* 表格容器 */
|
|
|
|
|
.table-wrapper {
|
|
|
|
|
overflow-x: auto;
|
|
|
|
|
-webkit-overflow-scrolling: touch;
|
|
|
|
|
background: white;
|
|
|
|
|
margin: 0;
|
|
|
|
|
}
|
|
|
|
|
table {
|
|
|
|
|
width: 100%;
|
|
|
|
|
min-width: 600px;
|
|
|
|
|
border-collapse: collapse;
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
}
|
|
|
|
|
thead {
|
|
|
|
|
background: #fafafa;
|
|
|
|
|
position: sticky;
|
|
|
|
|
top: 0;
|
|
|
|
|
}
|
|
|
|
|
th {
|
|
|
|
|
padding: 12px 10px;
|
|
|
|
|
text-align: left;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
color: #555;
|
|
|
|
|
border-bottom: 2px solid #e8e8e8;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
}
|
|
|
|
|
td {
|
|
|
|
|
padding: 10px;
|
|
|
|
|
border-bottom: 1px solid #f0f0f0;
|
|
|
|
|
word-break: break-all;
|
|
|
|
|
max-width: 200px;
|
|
|
|
|
}
|
|
|
|
|
tr:hover td { background: #f8f9fa; }
|
|
|
|
|
tr:last-child td { border-bottom: none; }
|
|
|
|
|
.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}
|
|
|
|
|
|
|
|
|
|
/* 序号列 */
|
|
|
|
|
.row-num {
|
|
|
|
|
color: #999;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
width: 36px;
|
|
|
|
|
text-align: center;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* 加载状态 */
|
|
|
|
|
.loading-box {
|
|
|
|
|
text-align: center;
|
|
|
|
|
padding: 60px 20px;
|
|
|
|
|
color: #999;
|
|
|
|
|
}
|
|
|
|
|
.loading-box .spinner {
|
|
|
|
|
width: 32px; height: 32px;
|
|
|
|
|
border: 3px solid #e8e8e8;
|
|
|
|
|
border-top-color: #1a73e8;
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
animation: spin 0.8s linear infinite;
|
|
|
|
|
margin: 0 auto 12px;
|
|
|
|
|
}
|
|
|
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
|
|
|
/* ===== 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)}
|
|
|
|
|
.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}
|
|
|
|
|
|
|
|
|
|
/* 空数据 */
|
|
|
|
|
.empty-box {
|
|
|
|
|
text-align: center;
|
|
|
|
|
padding: 60px 20px;
|
|
|
|
|
color: #999;
|
|
|
|
|
}
|
|
|
|
|
.empty-box .icon { font-size: 48px; margin-bottom: 12px; }
|
|
|
|
|
.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}
|
|
|
|
|
|
|
|
|
|
/* 底部信息 */
|
|
|
|
|
.footer {
|
|
|
|
|
padding: 16px;
|
|
|
|
|
text-align: center;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: #999;
|
|
|
|
|
}
|
|
|
|
|
.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)}
|
|
|
|
|
|
|
|
|
|
/* 滚动提示 */
|
|
|
|
|
.scroll-hint {
|
|
|
|
|
text-align: center;
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
color: #aaa;
|
|
|
|
|
padding: 6px;
|
|
|
|
|
background: white;
|
|
|
|
|
border-bottom: 1px solid #f0f0f0;
|
|
|
|
|
display: none;
|
|
|
|
|
}
|
|
|
|
|
@media (max-width: 640px) {
|
|
|
|
|
.scroll-hint { display: block; }
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
/* ===== 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="header">
|
|
|
|
|
<h1 id="reportTitle">{{REPORT_NAME}}</h1>
|
|
|
|
|
<div class="meta">
|
|
|
|
|
<span id="updateTime">加载中...</span>
|
|
|
|
|
<span id="dataSource"></span>
|
|
|
|
|
</div>
|
|
|
|
|
<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="toolbar">
|
|
|
|
|
<span class="count" id="recordCount">--</span>
|
|
|
|
|
<button class="refresh-btn" id="refreshBtn" onclick="loadData()">
|
|
|
|
|
↻ 刷新
|
|
|
|
|
</button>
|
|
|
|
|
<div class="summary" id="sumArea"></div>
|
|
|
|
|
<div class="report-desc" id="descArea">📊 加载中...</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<div class="flt">
|
|
|
|
|
<div class="flt-g"><label>区域</label><input id="f0" placeholder="全部" oninput="rf()"></div>
|
|
|
|
|
<div class="flt-g"><label>销售</label><input id="f1" placeholder="全部" oninput="rf()"></div>
|
|
|
|
|
<div class="flt-g"><label>产品</label><select id="f2" onchange="rf()"><option value="">全部</option><option>ERP</option><option>AI</option><option>培训</option><option>定制</option><option>看板</option></select></div>
|
|
|
|
|
<div class="flt-g"><label>状态</label><select id="f3" onchange="rf()"><option value="">全部</option><option>超额</option><option>完成</option><option>进行</option><option>滞后</option></select></div>
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
<div class="scroll-hint">← 左右滑动查看更多 →</div>
|
|
|
|
|
|
|
|
|
|
<div id="content">
|
|
|
|
|
<div class="loading-box">
|
|
|
|
|
<div class="spinner"></div>
|
|
|
|
|
<div>正在加载数据...</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flt" id="grpRow">
|
|
|
|
|
<div class="flt-g wide" style="min-width:100px;flex:0.3"><label>🔍</label><input id="fG" placeholder="全局搜索..." oninput="rf()" style="width:100%"></div>
|
|
|
|
|
<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="footer" id="footer"></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">
|
|
|
|
|
<button class="btn-excel" onclick="expXls()">⬇ 导出 Excel</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
const DATA_URL = '{{DATA_URL}}';
|
|
|
|
|
<div class="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>
|
|
|
|
|
|
|
|
|
|
async function loadData() {
|
|
|
|
|
const btn = document.getElementById('refreshBtn');
|
|
|
|
|
btn.classList.add('loading');
|
|
|
|
|
btn.innerHTML = '↻ 加载中...';
|
|
|
|
|
<div class="ft">
|
|
|
|
|
<div class="ft-info" id="ftInfo"></div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const resp = await fetch(DATA_URL);
|
|
|
|
|
const data = await resp.json();
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
if (data.error) {
|
|
|
|
|
showError(data.error);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
renderTable(data);
|
|
|
|
|
document.getElementById('updateTime').textContent =
|
|
|
|
|
'更新: ' + new Date().toLocaleString('zh-CN', {
|
|
|
|
|
month: '2-digit', day: '2-digit',
|
|
|
|
|
hour: '2-digit', minute: '2-digit'
|
|
|
|
|
});
|
|
|
|
|
document.getElementById('recordCount').textContent =
|
|
|
|
|
'共 ' + data.count + ' 条数据';
|
|
|
|
|
} catch (e) {
|
|
|
|
|
showError('网络错误,请稍后重试');
|
|
|
|
|
} finally {
|
|
|
|
|
btn.classList.remove('loading');
|
|
|
|
|
btn.innerHTML = '↻ 刷新';
|
|
|
|
|
}
|
|
|
|
|
<!-- ===== 数据源编辑弹窗 ===== -->
|
|
|
|
|
<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((_,i)=>i<REPORT_CONFIG.defaultVisible);
|
|
|
|
|
let sCol=6,sAsc=false;
|
|
|
|
|
let grpFields=REPORT_CONFIG.groupBy.map(g=>({...g,active:true}));
|
|
|
|
|
let grpExp={};
|
|
|
|
|
let fzCount=0;
|
|
|
|
|
let dark=true;
|
|
|
|
|
|
|
|
|
|
function init(){
|
|
|
|
|
$('rptTitle').textContent = REPORT_CONFIG.title || '报表';
|
|
|
|
|
data.sort((a,b)=>b.amount-a.amount);
|
|
|
|
|
initGrpExp();
|
|
|
|
|
buildColCheck();
|
|
|
|
|
buildGrpChips();
|
|
|
|
|
r();
|
|
|
|
|
}
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
function $(id){return document.getElementById(id)}
|
|
|
|
|
function rf(){r()}
|
|
|
|
|
|
|
|
|
|
function flt(){
|
|
|
|
|
const a=$('f0').value.trim().toLowerCase();
|
|
|
|
|
const b=$('f1').value.trim().toLowerCase();
|
|
|
|
|
const c=$('f2').value;
|
|
|
|
|
const d=$('f3').value;
|
|
|
|
|
const g=$('fG').value.trim().toLowerCase();
|
|
|
|
|
return data.filter(r=>{
|
|
|
|
|
if(a&&!r.region.toLowerCase().includes(a))return false;
|
|
|
|
|
if(b&&!r.person.toLowerCase().includes(b))return false;
|
|
|
|
|
if(c&&r.product!==c)return false;
|
|
|
|
|
if(d&&r.status!==d)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>';
|
|
|
|
|
|
|
|
|
|
function renderTable(data) {
|
|
|
|
|
const { fields, labels, records } = data;
|
|
|
|
|
if (!records || records.length === 0) {
|
|
|
|
|
document.getElementById('content').innerHTML =
|
|
|
|
|
'<div class="empty-box"><div class="icon">📄</div><div>暂无数据</div></div>';
|
|
|
|
|
return;
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let html = '<div class="table-wrapper"><table><thead><tr>';
|
|
|
|
|
html += '<th class="row-num">#</th>';
|
|
|
|
|
for (const f of fields) {
|
|
|
|
|
html += '<th>' + escapeHtml(labels[f] || f) + '</th>';
|
|
|
|
|
}
|
|
|
|
|
html += '</tr></thead><tbody>';
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < records.length; i++) {
|
|
|
|
|
const rec = records[i];
|
|
|
|
|
html += '<tr><td class="row-num">' + (i + 1) + '</td>';
|
|
|
|
|
for (const f of fields) {
|
|
|
|
|
let val = rec[f];
|
|
|
|
|
if (val === null || val === undefined || val === false) val = '';
|
|
|
|
|
if (Array.isArray(val)) val = val[1] || val[0] || '';
|
|
|
|
|
html += '<td>' + escapeHtml(String(val)) + '</td>';
|
|
|
|
|
}
|
|
|
|
|
html += '</tr>';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
html += '</tbody></table></div>';
|
|
|
|
|
document.getElementById('content').innerHTML = html;
|
|
|
|
|
document.getElementById('footer').textContent =
|
|
|
|
|
'数据由 AI 助手生成 · 有效期 7 天';
|
|
|
|
|
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':'';
|
|
|
|
|
const sa=r.status||'';
|
|
|
|
|
const tc=sa==='超额完成'||sa==='超额'?'tg-green':sa==='已完成'||sa==='完成'?'tg-blue':sa==='进行中'||sa==='进行'?'tg-amber':sa==='滞后'?'tg-red':'tg-gray';
|
|
|
|
|
// 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';
|
|
|
|
|
let h=c.k==='status'?`<span class="tag ${tc}">${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 showError(msg) {
|
|
|
|
|
document.getElementById('content').innerHTML =
|
|
|
|
|
'<div class="empty-box"><div class="icon">⚠</div><div>' +
|
|
|
|
|
escapeHtml(msg) + '</div></div>';
|
|
|
|
|
}
|
|
|
|
|
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');
|
|
|
|
|
// Description
|
|
|
|
|
const cities=[...new Set(fd.map(r=>r.city))].length;
|
|
|
|
|
const regions=[...new Set(fd.map(r=>r.region))].length;
|
|
|
|
|
const persons=[...new Set(fd.map(r=>r.person))].length;
|
|
|
|
|
const totalAmt=fd.reduce((s,r)=>s+(r.amount||0),0);
|
|
|
|
|
const totalOrd=fd.reduce((s,r)=>s+(r.orders||0),0);
|
|
|
|
|
const activeGrps=grpFields.filter(g=>g.active);
|
|
|
|
|
let groupInfo='';
|
|
|
|
|
if(activeGrps.length>0)groupInfo=' · 分组:'+activeGrps.map(g=>g.label).join(' → ');
|
|
|
|
|
const now=new Date();
|
|
|
|
|
$('descArea').innerHTML=
|
|
|
|
|
`<span>📊 数据概览:${regions} 个区域 · ${cities} 个城市 · ${persons} 位销售 · ${fd.length} 笔订单,总销售额 ¥${(totalAmt/10000).toFixed(1)}万,${totalOrd} 单</span>`+
|
|
|
|
|
(groupInfo?`<span>📂${groupInfo}</span>`:'')+
|
|
|
|
|
`<span style="color:var(--fg4)">🕐 ${now.toLocaleDateString('zh-CN')} ${now.toLocaleTimeString('zh-CN')}</span>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function escapeHtml(str) {
|
|
|
|
|
const div = document.createElement('div');
|
|
|
|
|
div.textContent = str;
|
|
|
|
|
return div.innerHTML;
|
|
|
|
|
}
|
|
|
|
|
// ===== 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();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 页面加载时获取数据
|
|
|
|
|
loadData();
|
|
|
|
|
</script>
|
|
|
|
|
// ===== 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((_,i)=>i<REPORT_CONFIG.defaultVisible);
|
|
|
|
|
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>
|
|
|
|
|