commit d2543779513c6c9b297f03fbdbc93f90e5e5f392
Author: fei550 <1420031550@qq.com>
Date: Mon Jun 22 10:48:35 2026 +0800
初始提交
diff --git a/indexV2.html b/indexV2.html
new file mode 100644
index 0000000..d8919f5
--- /dev/null
+++ b/indexV2.html
@@ -0,0 +1,77 @@
+
+
+
+
+
+ 集团数据治理项目监控大屏 V2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
DATA GOVERNANCE COMMAND CENTER
+
集团数据治理项目监控大屏
+
+
+
+
+
+
+
+
+
+
+
+
+
+
指标穿透分析
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/res/css/indexV2.base.css b/res/css/indexV2.base.css
new file mode 100644
index 0000000..933e914
--- /dev/null
+++ b/res/css/indexV2.base.css
@@ -0,0 +1,320 @@
+ :root {
+ --bg: #031b34;
+ --bg-deep: #001327;
+ --panel: rgba(9, 55, 91, 0.86);
+ --panel-strong: rgba(13, 74, 115, 0.92);
+ --panel-soft: rgba(18, 91, 132, 0.42);
+ --line: rgba(93, 191, 255, 0.32);
+ --line-strong: rgba(99, 213, 255, 0.64);
+ --text: #e8f7ff;
+ --muted: #92b9d1;
+ --blue: #5d93ff;
+ --cyan: #49d6ff;
+ --green: #5ee1b5;
+ --yellow: #ffc857;
+ --orange: #ff8b3d;
+ --red: #ff5c66;
+ --shadow: 0 18px 50px rgba(0, 8, 20, 0.42);
+ --radius: 18px;
+ --font: "Microsoft YaHei", "Source Han Sans SC", "PingFang SC", sans-serif;
+ --num: "DIN Alternate", "Bahnschrift", "Microsoft YaHei", sans-serif;
+ }
+
+ * {
+ box-sizing: border-box;
+ }
+
+ html,
+ body {
+ width: 100vw;
+ height: 100vh;
+ margin: 0;
+ background:
+ radial-gradient(circle at 14% 8%, rgba(29, 126, 177, 0.38), transparent 30%),
+ radial-gradient(circle at 86% 12%, rgba(31, 95, 155, 0.28), transparent 34%),
+ linear-gradient(180deg, #02152b 0%, #062b4a 48%, #031b34 100%);
+ color: var(--text);
+ font-family: var(--font);
+ overflow: hidden;
+ }
+
+ button,
+ input,
+ select {
+ font-family: inherit;
+ }
+
+ .screen {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 1920px;
+ height: 1080px;
+ min-height: 1080px;
+ margin: 0;
+ padding: 14px 18px 18px;
+ overflow: hidden;
+ isolation: isolate;
+ transform-origin: left top;
+ }
+
+ .screen::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ z-index: -2;
+ background-image:
+ linear-gradient(rgba(89, 185, 255, 0.06) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(89, 185, 255, 0.05) 1px, transparent 1px);
+ background-size: 100% 28px, 28px 100%;
+ opacity: 0.8;
+ pointer-events: none;
+ }
+
+ .screen::after {
+ content: "";
+ position: absolute;
+ inset: auto 0 0;
+ height: 38vh;
+ z-index: -1;
+ background: linear-gradient(180deg, transparent, rgba(0, 11, 23, 0.62));
+ pointer-events: none;
+ }
+
+ .topbar {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 14px;
+ align-items: start;
+ margin-bottom: 8px;
+ }
+
+ .title-zone {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ min-width: 0;
+ }
+
+ .brand-mark {
+ position: relative;
+ width: 52px;
+ height: 52px;
+ border-radius: 16px;
+ background:
+ linear-gradient(135deg, rgba(85, 214, 255, 0.96), rgba(45, 110, 255, 0.72)),
+ linear-gradient(180deg, rgba(255, 255, 255, 0.36), transparent);
+ box-shadow: 0 0 30px rgba(73, 214, 255, 0.34), inset 0 0 18px rgba(255, 255, 255, 0.18);
+ overflow: hidden;
+ flex: 0 0 auto;
+ }
+
+ .brand-mark::before,
+ .brand-mark::after {
+ content: "";
+ position: absolute;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.86);
+ }
+
+ .brand-mark::before {
+ width: 30px;
+ height: 8px;
+ left: 14px;
+ top: 16px;
+ box-shadow: 0 14px 0 rgba(255, 255, 255, 0.76), 0 28px 0 rgba(255, 255, 255, 0.56);
+ }
+
+ .brand-mark::after {
+ width: 8px;
+ height: 8px;
+ right: 13px;
+ top: 16px;
+ box-shadow: 0 14px 0 rgba(255, 255, 255, 0.76), 0 28px 0 rgba(255, 255, 255, 0.56);
+ }
+
+ .title-text {
+ min-width: 0;
+ }
+
+ .eyebrow {
+ color: var(--cyan);
+ font-size: 13px;
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
+ margin-bottom: 4px;
+ }
+
+ h1 {
+ margin: 0;
+ font-size: clamp(26px, 2.6vw, 42px);
+ line-height: 1.1;
+ letter-spacing: 0.04em;
+ text-shadow: 0 0 22px rgba(83, 202, 255, 0.44);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .purpose {
+ margin-top: 6px;
+ color: var(--muted);
+ font-size: 13px;
+ line-height: 1.5;
+ }
+
+ .header-actions {
+ display: grid;
+ gap: 8px;
+ justify-items: end;
+ }
+
+ .top-meta-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ justify-content: flex-end;
+ }
+
+ .source-download {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 36px;
+ padding: 8px 14px;
+ border: 1px solid rgba(83, 191, 255, 0.42);
+ border-radius: 14px;
+ color: #e7f8ff;
+ background: linear-gradient(180deg, rgba(27, 140, 208, 0.86), rgba(10, 90, 145, 0.86));
+ box-shadow: 0 0 18px rgba(73, 214, 255, 0.18), inset 0 1px 0 rgba(255, 255, 255, 0.14);
+ font-size: 13px;
+ font-weight: 900;
+ text-decoration: none;
+ white-space: nowrap;
+ }
+
+ .source-download:hover {
+ filter: brightness(1.12);
+ }
+
+ .time-card {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 8px 12px;
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ background: rgba(4, 28, 52, 0.72);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
+ color: var(--muted);
+ font-size: 13px;
+ }
+
+ .time-card strong {
+ color: var(--text);
+ font-family: var(--num);
+ letter-spacing: 0.04em;
+ }
+
+ .toggle-row {
+ display: flex;
+ gap: 10px;
+ justify-content: flex-end;
+ align-items: center;
+ }
+
+ .dimension-toggle,
+ .system-toggle,
+ .view-switch {
+ display: inline-flex;
+ padding: 4px;
+ border: 1px solid rgba(80, 165, 235, 0.28);
+ border-radius: 14px;
+ background: rgba(3, 20, 39, 0.74);
+ box-shadow: inset 0 1px 12px rgba(0, 0, 0, 0.18);
+ }
+
+ .dimension-toggle button,
+ .system-toggle button,
+ .view-switch button {
+ border: 0;
+ border-radius: 10px;
+ padding: 8px 14px;
+ color: #a9cbe0;
+ background: transparent;
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: 700;
+ transition: 0.18s ease;
+ }
+
+ .dimension-toggle button.active,
+ .system-toggle button.active,
+ .view-switch button.active {
+ color: white;
+ background: linear-gradient(180deg, #1b8cd0, #0a5a91);
+ box-shadow: 0 0 18px rgba(73, 214, 255, 0.22), inset 0 1px 0 rgba(255, 255, 255, 0.18);
+ }
+
+ .dimension-toggle button:hover,
+ .system-toggle button:hover,
+ .view-switch button:hover {
+ color: white;
+ }
+
+ .crumb-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+ margin: 8px 0 10px;
+ }
+
+ .breadcrumbs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: center;
+ color: var(--muted);
+ font-size: 13px;
+ }
+
+ .crumb {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 11px;
+ border: 1px solid rgba(87, 184, 255, 0.2);
+ border-radius: 999px;
+ background: rgba(6, 35, 62, 0.64);
+ }
+
+ .crumb.clickable {
+ cursor: pointer;
+ color: #bfeaff;
+ }
+
+ .crumb.clickable:hover {
+ border-color: var(--line-strong);
+ color: white;
+ }
+
+ .quick-actions {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+ }
+
+ .ghost-btn {
+ border: 1px solid rgba(83, 191, 255, 0.34);
+ color: #c7e9ff;
+ background: rgba(8, 43, 76, 0.62);
+ border-radius: 12px;
+ padding: 9px 13px;
+ cursor: pointer;
+ font-weight: 700;
+ }
+
+ .ghost-btn:hover {
+ background: rgba(18, 93, 142, 0.72);
+ }
diff --git a/res/css/indexV2.cards.css b/res/css/indexV2.cards.css
new file mode 100644
index 0000000..9bcd8e3
--- /dev/null
+++ b/res/css/indexV2.cards.css
@@ -0,0 +1,306 @@
+ .kpi-grid {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 10px;
+ margin-bottom: 10px;
+ }
+
+ .kpi-card,
+ .warning-card,
+ .panel,
+ .mini-panel {
+ position: relative;
+ border: 1px solid rgba(91, 180, 255, 0.26);
+ border-radius: var(--radius);
+ background:
+ linear-gradient(180deg, rgba(24, 91, 132, 0.88), rgba(4, 33, 63, 0.9)),
+ repeating-linear-gradient(0deg, rgba(108, 205, 255, 0.04), rgba(108, 205, 255, 0.04) 1px, transparent 1px, transparent 9px);
+ box-shadow: var(--shadow), inset 0 1px 0 rgba(255, 255, 255, 0.08);
+ overflow: hidden;
+ }
+
+ .kpi-card::before,
+ .warning-card::before,
+ .panel::before,
+ .mini-panel::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background:
+ radial-gradient(circle at 20% 0%, rgba(87, 214, 255, 0.18), transparent 32%),
+ radial-gradient(circle at 90% 20%, rgba(78, 127, 255, 0.14), transparent 28%);
+ pointer-events: none;
+ }
+
+ .kpi-card {
+ min-height: 104px;
+ padding: 13px 14px 11px;
+ }
+
+ .kpi-label,
+ .warning-label {
+ position: relative;
+ color: #c4e7ff;
+ font-size: 13px;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ margin-bottom: 7px;
+ }
+
+ .kpi-value {
+ position: relative;
+ display: flex;
+ align-items: baseline;
+ gap: 6px;
+ font-family: var(--num);
+ font-size: 30px;
+ font-weight: 900;
+ line-height: 1;
+ color: white;
+ text-shadow: 0 0 20px rgba(88, 210, 255, 0.34);
+ }
+
+ .kpi-unit {
+ color: var(--muted);
+ font-size: 14px;
+ font-weight: 700;
+ }
+
+ .kpi-desc {
+ position: relative;
+ margin-top: 8px;
+ color: #9fc6db;
+ font-size: 12px;
+ line-height: 1.45;
+ }
+
+ .kpi-trend {
+ position: absolute;
+ right: 14px;
+ top: 15px;
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 4px 8px;
+ border-radius: 999px;
+ background: rgba(0, 20, 39, 0.45);
+ color: #b8f4df;
+ font-size: 12px;
+ font-family: var(--num);
+ }
+
+ .kpi-trend.down {
+ color: #ffb9aa;
+ }
+
+ .risk-band {
+ position: relative;
+ margin-top: 8px;
+ height: 7px;
+ border-radius: 999px;
+ background: rgba(121, 185, 220, 0.18);
+ overflow: hidden;
+ }
+
+ .risk-band span {
+ display: block;
+ height: 100%;
+ border-radius: inherit;
+ background: linear-gradient(90deg, var(--green), var(--cyan));
+ }
+
+ .warning-strip {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 10px;
+ margin-bottom: 10px;
+ }
+
+ .focus-row {
+ display: grid;
+ grid-template-columns: 430px minmax(0, 1fr) 430px;
+ gap: 10px;
+ margin-bottom: 10px;
+ align-items: stretch;
+ }
+
+ .focus-indicators {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+ }
+
+ .warning-card {
+ min-height: 82px;
+ padding: 10px 12px;
+ border-color: rgba(255, 139, 61, 0.32);
+ }
+
+ .warning-card.high {
+ border-color: rgba(255, 92, 102, 0.48);
+ }
+
+ .warning-card.high::after {
+ content: "";
+ position: absolute;
+ inset: 0 0 auto;
+ height: 3px;
+ background: linear-gradient(90deg, var(--red), var(--orange));
+ }
+
+ .warning-name {
+ position: relative;
+ min-height: 24px;
+ color: #ffb46f;
+ font-size: 15px;
+ font-weight: 900;
+ line-height: 1.25;
+ }
+
+ .warning-lines {
+ position: relative;
+ display: grid;
+ gap: 2px;
+ margin-top: 5px;
+ color: #c7e7f8;
+ font-size: 12px;
+ line-height: 1.36;
+ }
+
+ .warning-lines strong {
+ color: white;
+ font-family: var(--num);
+ font-size: 14px;
+ }
+
+ .focus-row .warning-card {
+ min-height: 94px;
+ padding: 9px 10px;
+ }
+
+ .focus-row .warning-name {
+ min-height: 22px;
+ font-size: 14px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .focus-row .warning-lines {
+ gap: 1px;
+ font-size: 11px;
+ }
+
+ .focus-row .warning-lines strong {
+ font-size: 13px;
+ }
+
+ .enterprise-trend-panel {
+ min-height: 150px;
+ margin-bottom: 10px;
+ }
+
+ .enterprise-trend-panel .panel-header {
+ margin-bottom: 2px;
+ }
+
+ .enterprise-trend-panel .chart-box {
+ overflow-x: hidden;
+ }
+
+ .indicator-strip {
+ display: grid;
+ grid-template-columns: repeat(8, minmax(0, 1fr)) 84px;
+ gap: 10px;
+ margin-bottom: 10px;
+ align-items: stretch;
+ }
+
+ .indicator-strip .warning-card {
+ min-height: 90px;
+ padding: 9px 10px;
+ }
+
+ .indicator-strip .warning-name {
+ min-height: 34px;
+ font-size: 13px;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ }
+
+ .indicator-strip .warning-lines {
+ gap: 1px;
+ font-size: 11px;
+ line-height: 1.3;
+ }
+
+ .indicator-strip .warning-lines strong {
+ font-size: 12px;
+ }
+
+ .indicator-more-cell {
+ position: relative;
+ z-index: 1;
+ display: grid;
+ place-items: center;
+ min-height: 90px;
+ border: 1px solid rgba(91, 214, 255, 0.36);
+ border-radius: var(--radius);
+ background: rgba(4, 35, 64, 0.72);
+ box-shadow: var(--shadow), inset 0 1px 0 rgba(255, 255, 255, 0.08);
+ }
+
+ .trend-panel.group-trend-panel {
+ min-height: 150px;
+ margin-bottom: 10px;
+ }
+
+ .trend-panel.group-trend-panel .panel-header {
+ margin-bottom: 2px;
+ }
+
+ .trend-panel.group-trend-panel .chart-box {
+ overflow-x: hidden;
+ }
+
+ .group-bottom-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1.35fr) minmax(360px, 0.65fr);
+ gap: 12px;
+ align-items: stretch;
+ }
+
+ .group-left-stack {
+ display: grid;
+ grid-template-rows: 1fr 1fr;
+ gap: 10px;
+ min-height: 388px;
+ }
+
+ .group-left-stack .panel {
+ min-height: 189px;
+ }
+
+ .group-left-stack .chart-box {
+ overflow-y: hidden;
+ }
+
+ .usage-report-panel .table-wrap {
+ max-height: 136px;
+ }
+
+ .usage-report-panel table {
+ min-width: 620px;
+ font-size: 12px;
+ }
+
+ .usage-report-panel th,
+ .usage-report-panel td {
+ padding: 7px 9px;
+ }
+
+ .usage-report-panel .panel-header {
+ margin-bottom: 6px;
+ }
diff --git a/res/css/indexV2.detail.css b/res/css/indexV2.detail.css
new file mode 100644
index 0000000..2d00a34
--- /dev/null
+++ b/res/css/indexV2.detail.css
@@ -0,0 +1,101 @@
+ .metric-cards {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 12px;
+ margin-bottom: 12px;
+ }
+
+ .mini-panel {
+ padding: 14px;
+ min-height: 116px;
+ }
+
+ .mini-panel[data-action] {
+ cursor: pointer;
+ }
+
+ .mini-panel[data-action]:hover {
+ border-color: rgba(91, 214, 255, 0.52);
+ filter: brightness(1.08);
+ }
+
+ .mini-label {
+ position: relative;
+ color: #a8cbe1;
+ font-size: 13px;
+ margin-bottom: 8px;
+ }
+
+ .mini-value {
+ position: relative;
+ font-family: var(--num);
+ font-size: 30px;
+ line-height: 1;
+ color: white;
+ font-weight: 900;
+ }
+
+ .mini-desc {
+ position: relative;
+ margin-top: 10px;
+ color: #94bad1;
+ font-size: 12px;
+ }
+
+ .project-layout {
+ display: grid;
+ grid-template-columns: minmax(320px, 0.72fr) minmax(0, 1.28fr);
+ gap: 12px;
+ align-items: start;
+ }
+
+ .field-list {
+ position: relative;
+ z-index: 1;
+ display: grid;
+ gap: 9px;
+ }
+
+ .field-row {
+ display: grid;
+ grid-template-columns: 112px 1fr;
+ gap: 12px;
+ padding: 11px 12px;
+ border-radius: 13px;
+ background: rgba(0, 18, 37, 0.36);
+ border: 1px solid rgba(91, 180, 255, 0.14);
+ }
+
+ .field-name {
+ color: #91bdd5;
+ font-size: 13px;
+ }
+
+ .field-value {
+ color: white;
+ font-weight: 800;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .empty-state {
+ position: relative;
+ z-index: 1;
+ min-height: 220px;
+ display: grid;
+ place-items: center;
+ color: #9ec2da;
+ border: 1px dashed rgba(115, 198, 255, 0.25);
+ border-radius: 14px;
+ background: rgba(0, 16, 31, 0.24);
+ }
+
+ .explain {
+ position: relative;
+ z-index: 1;
+ margin-top: 10px;
+ color: #9fc2d9;
+ font-size: 12px;
+ line-height: 1.5;
+ }
diff --git a/res/css/indexV2.modal-table.css b/res/css/indexV2.modal-table.css
new file mode 100644
index 0000000..b30057a
--- /dev/null
+++ b/res/css/indexV2.modal-table.css
@@ -0,0 +1,351 @@
+ .drill-hero {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) repeat(3, 190px);
+ gap: 12px;
+ margin-bottom: 12px;
+ }
+
+ .drill-title-card {
+ position: relative;
+ min-height: 118px;
+ padding: 18px;
+ border: 1px solid rgba(91, 180, 255, 0.28);
+ border-radius: var(--radius);
+ background:
+ linear-gradient(135deg, rgba(13, 80, 126, 0.95), rgba(3, 26, 53, 0.92)),
+ repeating-linear-gradient(0deg, rgba(108, 205, 255, 0.04), rgba(108, 205, 255, 0.04) 1px, transparent 1px, transparent 9px);
+ box-shadow: var(--shadow);
+ overflow: hidden;
+ }
+
+ .drill-title-card h2 {
+ position: relative;
+ margin: 0;
+ font-size: 28px;
+ line-height: 1.2;
+ }
+
+ .drill-title-card p {
+ position: relative;
+ margin: 10px 0 0;
+ color: #a9cfe5;
+ font-size: 13px;
+ line-height: 1.5;
+ }
+
+ .drill-card {
+ position: relative;
+ min-height: 118px;
+ padding: 14px;
+ border: 1px solid rgba(91, 180, 255, 0.24);
+ border-radius: var(--radius);
+ background: linear-gradient(180deg, rgba(19, 84, 126, 0.88), rgba(3, 32, 61, 0.92));
+ box-shadow: var(--shadow);
+ }
+
+ .drill-card-label {
+ color: #a8cbe1;
+ font-size: 13px;
+ }
+
+ .drill-card-value {
+ margin-top: 10px;
+ font-family: var(--num);
+ font-size: 32px;
+ font-weight: 900;
+ color: white;
+ }
+
+ .drill-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1.2fr) minmax(420px, 0.8fr);
+ gap: 12px;
+ align-items: stretch;
+ }
+
+ .drill-grid.equal {
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .drill-panel {
+ min-height: 690px;
+ }
+
+ .rank-list {
+ position: relative;
+ z-index: 1;
+ display: grid;
+ gap: 9px;
+ max-height: 600px;
+ overflow-y: auto;
+ padding-right: 4px;
+ }
+
+ .rank-row {
+ display: grid;
+ grid-template-columns: 42px 1fr 94px;
+ gap: 12px;
+ align-items: center;
+ padding: 10px 12px;
+ border: 1px solid rgba(91, 180, 255, 0.16);
+ border-radius: 13px;
+ background: rgba(1, 21, 42, 0.42);
+ cursor: pointer;
+ }
+
+ .rank-row:hover {
+ border-color: rgba(91, 214, 255, 0.46);
+ background: rgba(12, 62, 98, 0.62);
+ }
+
+ .rank-row:not([data-indicator-enterprise]) {
+ cursor: default;
+ }
+
+ .rank-index {
+ width: 30px;
+ height: 30px;
+ display: inline-grid;
+ place-items: center;
+ border-radius: 10px;
+ background: rgba(93, 147, 255, 0.2);
+ color: #dff7ff;
+ font-family: var(--num);
+ font-weight: 900;
+ }
+
+ .rank-name {
+ color: #f0fbff;
+ font-weight: 900;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .rank-bars {
+ display: grid;
+ gap: 6px;
+ margin-top: 7px;
+ }
+
+ .rank-value {
+ text-align: right;
+ font-family: var(--num);
+ color: white;
+ font-size: 18px;
+ font-weight: 900;
+ }
+
+ .drill-modal[hidden] {
+ display: none;
+ }
+
+ .drill-modal {
+ position: fixed;
+ inset: 0;
+ z-index: 100;
+ display: grid;
+ place-items: center;
+ padding: 34px;
+ background: rgba(0, 10, 24, 0.72);
+ backdrop-filter: blur(8px);
+ }
+
+ .drill-modal-panel {
+ width: min(1780px, calc(100vw - 68px));
+ height: min(960px, calc(100vh - 68px));
+ border: 1px solid rgba(91, 214, 255, 0.44);
+ border-radius: 24px;
+ background:
+ linear-gradient(180deg, rgba(8, 49, 83, 0.98), rgba(1, 18, 36, 0.98)),
+ repeating-linear-gradient(0deg, rgba(108, 205, 255, 0.04), rgba(108, 205, 255, 0.04) 1px, transparent 1px, transparent 9px);
+ box-shadow: 0 28px 80px rgba(0, 0, 0, 0.52), inset 0 1px 0 rgba(255, 255, 255, 0.1);
+ overflow: hidden;
+ display: grid;
+ grid-template-rows: auto 1fr;
+ }
+
+ .drill-modal-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 16px;
+ padding: 14px 18px;
+ border-bottom: 1px solid rgba(91, 180, 255, 0.22);
+ background: linear-gradient(180deg, rgba(22, 102, 151, 0.72), rgba(4, 38, 72, 0.72));
+ }
+
+ .drill-modal-title {
+ color: #f2fbff;
+ font-size: 20px;
+ font-weight: 900;
+ letter-spacing: 0.02em;
+ }
+
+ .drill-modal-actions {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ }
+
+ .modal-close {
+ width: 34px;
+ height: 34px;
+ border: 1px solid rgba(147, 221, 255, 0.38);
+ border-radius: 999px;
+ color: white;
+ background: rgba(5, 41, 76, 0.84);
+ cursor: pointer;
+ font-size: 20px;
+ line-height: 1;
+ }
+
+ .modal-close:hover {
+ background: rgba(24, 117, 166, 0.9);
+ }
+
+ .drill-modal-body {
+ padding: 16px;
+ overflow: auto;
+ }
+
+ .drill-modal-body .drill-panel {
+ min-height: 600px;
+ }
+
+ .drill-modal-body .rank-list {
+ max-height: 510px;
+ }
+
+ .panel-tools {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ }
+
+ .more-btn {
+ border: 1px solid rgba(91, 214, 255, 0.42);
+ border-radius: 999px;
+ padding: 7px 13px;
+ color: #e3f8ff;
+ background: linear-gradient(180deg, rgba(28, 153, 217, 0.82), rgba(5, 72, 118, 0.86));
+ cursor: pointer;
+ font-size: 12px;
+ font-weight: 900;
+ box-shadow: 0 0 16px rgba(73, 214, 255, 0.18);
+ }
+
+ .more-btn:hover {
+ filter: brightness(1.12);
+ }
+
+ .table-status {
+ position: relative;
+ z-index: 1;
+ margin-bottom: 10px;
+ padding: 8px 12px;
+ border: 1px solid rgba(91, 214, 255, 0.28);
+ border-radius: 8px;
+ color: #dff7ff;
+ background: rgba(4, 35, 64, 0.72);
+ font-size: 13px;
+ font-weight: 700;
+ }
+
+ .table-status.error {
+ border-color: rgba(255, 92, 102, 0.4);
+ color: #ffd6d9;
+ background: rgba(99, 24, 36, 0.42);
+ }
+
+ table {
+ width: 100%;
+ border-collapse: collapse;
+ min-width: 760px;
+ color: #dcefff;
+ font-size: 13px;
+ }
+
+ th {
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ padding: 11px 10px;
+ background: linear-gradient(180deg, rgba(28, 153, 217, 0.96), rgba(5, 72, 118, 0.96));
+ color: white;
+ text-align: left;
+ border-bottom: 1px solid rgba(147, 221, 255, 0.38);
+ white-space: nowrap;
+ }
+
+ td {
+ padding: 10px;
+ border-bottom: 1px solid rgba(147, 221, 255, 0.12);
+ color: #cfe7f6;
+ white-space: nowrap;
+ }
+
+ tr:hover td {
+ background: rgba(39, 126, 178, 0.16);
+ }
+
+ tr.summary-row td {
+ color: #ffffff;
+ font-weight: 900;
+ background: rgba(73, 214, 255, 0.14);
+ }
+
+ tr.green-row td {
+ color: #d9fff1;
+ background: rgba(94, 225, 181, 0.18);
+ }
+
+ tr.clickable-row {
+ cursor: pointer;
+ }
+
+ .num {
+ font-family: var(--num);
+ color: white;
+ font-size: 15px;
+ font-weight: 800;
+ }
+
+ .delta-up {
+ color: #7df5c5;
+ }
+
+ .delta-down {
+ color: #ff9d94;
+ }
+
+ .status-pill {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 58px;
+ padding: 4px 8px;
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 800;
+ }
+
+ .status-pill.ok {
+ color: #bdf9e4;
+ background: rgba(94, 225, 181, 0.14);
+ border: 1px solid rgba(94, 225, 181, 0.36);
+ }
+
+ .status-pill.warn {
+ color: #ffe2ad;
+ background: rgba(255, 200, 87, 0.14);
+ border: 1px solid rgba(255, 200, 87, 0.36);
+ }
+
+ .status-pill.bad {
+ color: #ffc2c7;
+ background: rgba(255, 92, 102, 0.14);
+ border: 1px solid rgba(255, 92, 102, 0.36);
+ }
diff --git a/res/css/indexV2.panels.css b/res/css/indexV2.panels.css
new file mode 100644
index 0000000..381e082
--- /dev/null
+++ b/res/css/indexV2.panels.css
@@ -0,0 +1,318 @@
+ .dashboard-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1.35fr) minmax(360px, 0.65fr);
+ gap: 12px;
+ align-items: stretch;
+ }
+
+ .dashboard-grid.group-overview {
+ align-items: stretch;
+ }
+
+ .dashboard-grid.enterprise-overview {
+ grid-template-columns: minmax(0, 1.35fr) minmax(360px, 0.65fr);
+ gap: 12px;
+ }
+
+ .dashboard-grid.enterprise-overview .panel {
+ min-height: 290px;
+ }
+
+ .dashboard-grid.enterprise-overview .risk-summary {
+ gap: 7px;
+ max-height: 226px;
+ overflow-y: auto;
+ padding-right: 4px;
+ }
+
+ .dashboard-grid.enterprise-overview .risk-item {
+ grid-template-columns: 64px 1fr auto;
+ gap: 9px;
+ padding: 7px 9px;
+ }
+
+ .dashboard-grid.enterprise-overview .mini-bar {
+ grid-template-columns: 42px 1fr 38px;
+ gap: 6px;
+ font-size: 11px;
+ }
+
+ .dashboard-grid.enterprise-overview .mini-track {
+ height: 5px;
+ }
+
+ .dashboard-grid.equal {
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .subgrid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+ margin-top: 12px;
+ }
+
+ .panel {
+ min-height: 360px;
+ padding: 16px;
+ }
+
+ .panel.compact {
+ min-height: 270px;
+ }
+
+ .panel.tall {
+ min-height: 450px;
+ }
+
+ .trend-panel {
+ min-height: 198px;
+ padding: 12px 14px;
+ }
+
+ .trend-panel .panel-header {
+ margin-bottom: 4px;
+ }
+
+ .panel-header {
+ position: relative;
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 14px;
+ margin-bottom: 10px;
+ z-index: 1;
+ }
+
+ .panel-title {
+ margin: 0;
+ color: #f1fbff;
+ font-size: 20px;
+ font-weight: 900;
+ letter-spacing: 0.02em;
+ }
+
+ .panel-note {
+ margin-top: 6px;
+ color: #9fc2d9;
+ font-size: 12px;
+ line-height: 1.45;
+ }
+
+ .legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ justify-content: flex-end;
+ color: #a9cde2;
+ font-size: 12px;
+ white-space: nowrap;
+ }
+
+ .legend-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ }
+
+ .dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 3px;
+ display: inline-block;
+ }
+
+ .chart-box {
+ position: relative;
+ z-index: 1;
+ overflow-x: auto;
+ overflow-y: hidden;
+ scrollbar-color: rgba(86, 151, 239, 0.72) rgba(30, 89, 130, 0.42);
+ scrollbar-width: thin;
+ padding-bottom: 2px;
+ }
+
+ .chart-box::-webkit-scrollbar,
+ .table-wrap::-webkit-scrollbar {
+ height: 9px;
+ width: 9px;
+ }
+
+ .chart-box::-webkit-scrollbar-thumb,
+ .table-wrap::-webkit-scrollbar-thumb {
+ background: rgba(78, 145, 226, 0.74);
+ border-radius: 999px;
+ }
+
+ .chart-box::-webkit-scrollbar-track,
+ .table-wrap::-webkit-scrollbar-track {
+ background: rgba(22, 69, 105, 0.42);
+ border-radius: 999px;
+ }
+
+ svg {
+ display: block;
+ max-width: none;
+ font-family: var(--font);
+ }
+
+ .axis {
+ stroke: rgba(155, 211, 247, 0.28);
+ stroke-width: 1;
+ }
+
+ .grid-line {
+ stroke: rgba(155, 211, 247, 0.16);
+ stroke-width: 1;
+ }
+
+ .axis-label {
+ fill: #a8c7db;
+ font-size: 12px;
+ }
+
+ .bar-label,
+ .point-label {
+ fill: #dff7ff;
+ font-size: 12px;
+ font-family: var(--num);
+ font-weight: 700;
+ }
+
+ .chart-click {
+ cursor: pointer;
+ transition: 0.16s ease;
+ }
+
+ .chart-click:hover {
+ filter: brightness(1.2) drop-shadow(0 0 8px rgba(91, 217, 255, 0.46));
+ }
+
+ .risk-summary {
+ position: relative;
+ z-index: 1;
+ display: grid;
+ gap: 10px;
+ }
+
+ .risk-item {
+ display: grid;
+ grid-template-columns: 72px 1fr auto;
+ gap: 12px;
+ align-items: center;
+ padding: 10px 11px;
+ border: 1px solid rgba(91, 180, 255, 0.14);
+ border-radius: 13px;
+ background: rgba(1, 21, 42, 0.42);
+ cursor: pointer;
+ }
+
+ .risk-item:hover {
+ border-color: rgba(91, 214, 255, 0.46);
+ background: rgba(12, 62, 98, 0.62);
+ }
+
+ .risk-panel {
+ min-height: 450px;
+ height: 100%;
+ }
+
+ .risk-panel .risk-summary {
+ gap: 8px;
+ max-height: 366px;
+ overflow-y: auto;
+ padding-right: 4px;
+ }
+
+ .risk-panel .risk-item {
+ grid-template-columns: 64px 1fr auto;
+ gap: 10px;
+ padding: 8px 10px;
+ }
+
+ .risk-panel .mini-bar {
+ grid-template-columns: 42px 1fr 40px;
+ gap: 6px;
+ font-size: 11px;
+ }
+
+ .risk-panel .mini-track {
+ height: 5px;
+ }
+
+ .risk-panel .risk-badge {
+ padding: 4px 8px;
+ font-size: 11px;
+ }
+
+ .risk-name {
+ color: #e8f8ff;
+ font-weight: 800;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .risk-bars {
+ display: grid;
+ gap: 6px;
+ }
+
+ .mini-bar {
+ display: grid;
+ grid-template-columns: 52px 1fr 34px;
+ gap: 8px;
+ align-items: center;
+ color: #9fc4dc;
+ font-size: 12px;
+ }
+
+ .mini-track {
+ height: 7px;
+ border-radius: 999px;
+ background: rgba(126, 184, 219, 0.16);
+ overflow: hidden;
+ }
+
+ .mini-track span {
+ display: block;
+ height: 100%;
+ border-radius: inherit;
+ }
+
+ .risk-badge {
+ padding: 5px 9px;
+ border-radius: 999px;
+ color: white;
+ font-size: 12px;
+ font-weight: 800;
+ white-space: nowrap;
+ }
+
+ .risk-badge.red {
+ background: rgba(255, 92, 102, 0.22);
+ border: 1px solid rgba(255, 92, 102, 0.42);
+ color: #ffc2c7;
+ }
+
+ .risk-badge.orange {
+ background: rgba(255, 139, 61, 0.2);
+ border: 1px solid rgba(255, 139, 61, 0.44);
+ color: #ffd1ad;
+ }
+
+ .risk-badge.green {
+ background: rgba(94, 225, 181, 0.16);
+ border: 1px solid rgba(94, 225, 181, 0.42);
+ color: #bdf9e4;
+ }
+
+ .table-wrap {
+ position: relative;
+ z-index: 1;
+ overflow: auto;
+ max-height: 545px;
+ border: 1px solid rgba(91, 180, 255, 0.18);
+ border-radius: 14px;
+ background: rgba(1, 18, 36, 0.34);
+ }
diff --git a/res/css/indexV2.responsive.css b/res/css/indexV2.responsive.css
new file mode 100644
index 0000000..6b17be1
--- /dev/null
+++ b/res/css/indexV2.responsive.css
@@ -0,0 +1,42 @@
+ @media (max-width: 1px) {
+ .kpi-grid,
+ .warning-strip {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+
+ .dashboard-grid,
+ .dashboard-grid.equal,
+ .project-layout {
+ grid-template-columns: 1fr;
+ }
+ }
+
+ @media (max-width: 1px) {
+ .screen {
+ padding: 14px;
+ }
+
+ .topbar {
+ grid-template-columns: 1fr;
+ }
+
+ .header-actions {
+ justify-items: start;
+ }
+
+ h1 {
+ white-space: normal;
+ }
+
+ .kpi-grid,
+ .warning-strip,
+ .metric-cards,
+ .subgrid {
+ grid-template-columns: 1fr;
+ }
+
+ .crumb-row {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+ }
diff --git a/res/js/indexV2.charts.js b/res/js/indexV2.charts.js
new file mode 100644
index 0000000..9b992c8
--- /dev/null
+++ b/res/js/indexV2.charts.js
@@ -0,0 +1,180 @@
+ /**
+ * SVG 图表渲染模块。
+ * 所有图表都返回字符串,由视图层直接写入 chart-box;交互点通过 data-enterprise/data-project 交给事件代理处理。
+ */
+ function renderGroupedRateChart(list, mode, options = {}) {
+ const width = Math.max(1180, list.length * 88 + 120);
+ const height = options.height || 350;
+ const margin = { top: 20, right: 30, bottom: height < 240 ? 58 : 98, left: 54 };
+ const chartHeight = height - margin.top - margin.bottom;
+ const chartWidth = width - margin.left - margin.right;
+ const groupWidth = chartWidth / Math.max(list.length, 1);
+ const barWidth = Math.min(height < 240 ? 12 : 16, groupWidth / 5);
+ const series = [
+ { key: "onlineRate", label: "上线", color: COLORS.online },
+ { key: "usageRate", label: "使用", color: COLORS.usage },
+ { key: "accuracyRate", label: "准确", color: COLORS.accuracy }
+ ];
+ const grid = [0, 25, 50, 75, 100].map((tick) => {
+ const y = margin.top + chartHeight - (tick / 100) * chartHeight;
+ return `${tick}%`;
+ }).join("");
+
+ const bars = list.map((enterprise, index) => {
+ const stats = enterprise.stats[state.dimension];
+ const center = margin.left + groupWidth * index + groupWidth / 2;
+ const label = enterprise.name;
+ const rects = series.map((item, seriesIndex) => {
+ const value = stats[item.key];
+ const barHeight = (value / 100) * chartHeight;
+ const x = center - barWidth * 1.8 + seriesIndex * barWidth * 1.8;
+ const y = margin.top + chartHeight - barHeight;
+ return `
+
+ ${Math.round(value)}
+ `;
+ }).join("");
+ return `
+ ${rects}
+ ${label}
+ `;
+ }).join("");
+
+ return `
+
+ `;
+ }
+
+ /**
+ * 项目使用率/准确率柱状图。
+ * 柱子用于进入项目明细,底部圆点表达项目是否已上线,便于在企业层同时查看使用和上线状态。
+ */
+ function renderProjectRateChart(projects, options = {}) {
+ const width = Math.max(1100, projects.length * 126 + 120);
+ const height = options.height || 350;
+ const margin = { top: 20, right: 30, bottom: height < 260 ? 66 : 98, left: 54 };
+ const chartHeight = height - margin.top - margin.bottom;
+ const chartWidth = width - margin.left - margin.right;
+ const groupWidth = chartWidth / Math.max(projects.length, 1);
+ const barWidth = Math.min(20, groupWidth / 4);
+ const series = [
+ { key: "usageRate", color: COLORS.usage },
+ { key: "accuracyRate", color: COLORS.accuracy }
+ ];
+ const grid = [0, 25, 50, 75, 100].map((tick) => {
+ const y = margin.top + chartHeight - (tick / 100) * chartHeight;
+ return `${tick}%`;
+ }).join("");
+
+ const bars = projects.map((project, index) => {
+ const center = margin.left + groupWidth * index + groupWidth / 2;
+ const shortName = project.name.replace(project.unit, "").replace("项目", "");
+ const rects = series.map((item, seriesIndex) => {
+ const value = project[item.key];
+ const barHeight = (value / 100) * chartHeight;
+ const x = center - barWidth * 1.05 + seriesIndex * barWidth * 2.1;
+ const y = margin.top + chartHeight - barHeight;
+ return `
+
+ ${Math.round(value)}
+ `;
+ }).join("");
+ const statusY = height - (height < 260 ? 48 : 78);
+ const statusColor = project.created ? COLORS.cyan : COLORS.warning;
+ const statusText = project.created ? "已上线" : "未上线";
+ return `
+ ${rects}
+
+ ${statusText}
+ ${shortName}
+ `;
+ }).join("");
+
+ return `
+
+ `;
+ }
+
+ /**
+ * 集团趋势加权平均。
+ * 按企业项目数加权汇总各企业七日趋势,避免项目少的企业对集团曲线影响过大。
+ */
+ function averageTrend(list, key) {
+ const active = list.filter((enterprise) => enterprise.stats[state.dimension].projectCount > 0);
+ return Array.from({ length: 7 }, (_, index) => {
+ if (!active.length) return 0;
+ const weighted = active.reduce((sum, enterprise) => {
+ const projects = enterprise.stats[state.dimension].projectCount;
+ return sum + enterprise.trend[key][index] * projects;
+ }, 0);
+ const total = active.reduce((sum, enterprise) => sum + enterprise.stats[state.dimension].projectCount, 0);
+ return weighted / total;
+ });
+ }
+
+ /**
+ * 集团三率趋势图。
+ * 先把企业趋势压成集团趋势,再复用通用折线图渲染方法。
+ */
+ function renderGroupTrendChart(list) {
+ return renderTrendChart({
+ online: averageTrend(list, "online"),
+ usage: averageTrend(list, "usage"),
+ accuracy: averageTrend(list, "accuracy")
+ }, 1840, 112);
+ }
+
+ /**
+ * 通用三率折线图。
+ * data 需要包含 online/usage/accuracy 三组七日数组,适用于集团趋势和企业趋势。
+ */
+ function renderTrendChart(data, width = 720, height = 250) {
+ const margin = { top: 14, right: 28, bottom: 28, left: 46 };
+ const chartWidth = width - margin.left - margin.right;
+ const chartHeight = height - margin.top - margin.bottom;
+ const dates = ["04-21", "04-22", "04-23", "04-24", "04-25", "04-26", "04-27"];
+ const series = [
+ { key: "online", label: "上线", color: COLORS.online },
+ { key: "usage", label: "使用", color: COLORS.usage },
+ { key: "accuracy", label: "准确", color: COLORS.accuracy }
+ ];
+ const grid = [0, 25, 50, 75, 100].map((tick) => {
+ const y = margin.top + chartHeight - (tick / 100) * chartHeight;
+ return `${tick}%`;
+ }).join("");
+ const lines = series.map((item) => {
+ const values = data[item.key] || [];
+ const points = values.map((value, index) => {
+ const x = margin.left + (chartWidth / (values.length - 1)) * index;
+ const y = margin.top + chartHeight - (value / 100) * chartHeight;
+ return `${x},${y}`;
+ }).join(" ");
+ const circles = values.map((value, index) => {
+ const x = margin.left + (chartWidth / (values.length - 1)) * index;
+ const y = margin.top + chartHeight - (value / 100) * chartHeight;
+ return ``;
+ }).join("");
+ return `${circles}`;
+ }).join("");
+ const labels = dates.map((date, index) => {
+ const x = margin.left + (chartWidth / (dates.length - 1)) * index;
+ return `${date}`;
+ }).join("");
+
+ return `
+
+ `;
+ }
diff --git a/res/js/indexV2.config.js b/res/js/indexV2.config.js
new file mode 100644
index 0000000..766c383
--- /dev/null
+++ b/res/js/indexV2.config.js
@@ -0,0 +1,376 @@
+ /**
+ * 看板全局配置模块。
+ * 这里集中维护日期、外链、接口注册、兜底数据和运行时状态,其他 JS 模块只读取这些常量。
+ * 新增业务接口时优先注册到 apiRegistry,再通过 core 模块的统一请求方法调用,避免在视图层散落接口地址和 token。
+ */
+ const TODAY = "2026-04-27";
+ const PREVIOUS_DAY = "2026-04-26";
+ const INDICATOR_MORE_URL = "https://psecc.cncecoa.com/webroot/decision/v10/entry/access/e6251c34-31ea-4a35-8269-b83074bd9c64?preview=true";
+ const ENTERPRISE_INDICATOR_MORE_URL = "https://psecc.cncecoa.com/webroot/decision/view/report?viewlet=%252F%25E6%2595%25B0%25E7%25A7%2591%25E5%2585%25AC%25E5%258F%25B8%252F%25E5%25AE%2589%25E8%25B4%25A8%25E9%2583%25A8%25E6%2595%25B0%25E6%258D%25AE%25E6%25BA%25AF%25E6%25BA%2590%252F%25E5%25AE%2589%25E5%2585%25A8%25E6%2595%25B0%25E6%258D%25AE%25E5%25B1%2595%25E7%25A4%25BA_%25E4%25BC%2581%25E4%25B8%259A%25E7%25BA%25A7.cpt&op=view&__parameters__=%257B%2522__pi__%2522%253Atrue%252C%2522fine_hyperlink%2522%253A%2522ec19fc0a-9bfe-44f8-ba96-8402ba9b7bf2%2522%257D&_=1777453619793";
+ const GROUP_USAGE_REPORT_URL = "https://lygcgs.com.cn:8078/QHSEDataExChange/res/report/%E6%95%B0%E6%8D%AE%E6%B2%BB%E7%90%86%E8%A7%A3%E5%86%B3%E6%96%B9%E6%A1%88%E5%8F%8A%E6%95%B0%E6%8D%AE%E5%88%86%E6%9E%90%E6%8A%A5%E5%91%8A.pdf";
+ const OFFICE_PREVIEW_BASE_URL = "https://view.officeapps.live.com/op/embed.aspx?src=";
+ const REPORT_FILE_BASE_URL = "https://sggl.sedin.com.cn/res/report/";
+ /**
+ * 接口注册表。
+ * 当前集团项目上线率卡片和“集团项目上线统计”弹窗依赖 projectMasterDataAnalysis。
+ * Header 中仅保留后端要求的 token 和 Accept,浏览器侧不主动写 Host/Cookie/Connection 等受限头。
+ */
+ const apiRegistry = {
+ projectMasterDataAnalysis: {
+ method: "GET",
+ url: "https://qhse.cncecoa.com/qhse_webapi/api/Common/GetProjectMasterDataAnalysis?unitId=",
+ headers: {
+ token: "AF17168B-87BD-4GLY-1111-F0A0A1158F9B",
+ Accept: "*/*"
+ }
+ }
+ };
+ /**
+ * 本地兜底表头和行数据。
+ * 接口加载失败或跨域被拦截时仍展示旧 Excel 口径,避免主卡片和弹窗空白。
+ */
+ const usageReportHeaders = ["\u5e8f\u53f7", "\u5355\u4f4d", "\u4e3b\u6570\u636e\u9879\u76ee\u603b\u6570", "\u7533\u8bf7QHSE\u672a\u5efa\u9879\u76ee\u6570", "QHSE\u9879\u76ee\u603b\u6570\u6570", "QHSE\u4e0e\u4e3b\u6570\u636e\u5173\u8054\u9879\u76ee\u6570", "QHSE\u9879\u76ee\u5173\u8054\u4e0d\u5230\u4e3b\u6570\u636e\u9879\u76ee\u6570"];
+ const projectMasterDataHeaders = ["序号", "单位", "主数据项目数", "申请不用项目数", "QHSE使用项目数", "已关联", "待建", "预立项", "上报日期"];
+ const usageReportRows = [["1","\u5929\u8fb0","67","34","42","33","9"],["2","\u8d5b\u9f0e","33","3","52","30","22"],["3","\u4e1c\u534e","40","11","33","29","4"],["4","\u4e94\u73af","51","22","29","29","0"],["5","\u534e\u9646","38","4","34","34","0"],["6","\u6210\u8fbe","24","6","19","18","1"],["7","\u4e8c\u5316\u5efa","234","78","166","156","10"],["8","\u4e09\u5316\u5efa","167","36","151","131","20"],["9","\u56db\u5316\u5efa","117","46","84","71","13"],["10","\u516d\u5316\u5efa","263","90","199","173","26"],["11","\u4e03\u5316\u5efa","24","4","22","20","2"],["12","\u4e5d\u5316\u5efa","36","19","19","17","2"],["13","\u5341\u4e00\u5316\u5efa","69","17","57","52","5"],["14","\u5341\u4e09\u5316\u5efa","90","27","70","63","7"],["15","\u5341\u56db\u5316\u5efa","74","1","78","73","5"],["16","\u5341\u516d\u5316\u5efa","177","78","110","99","11"],["17","\u571f\u6728","42","11","33","31","2"],["18","\u88c5\u5907","14","5","9","9","0"],["19","\u534e\u8c0a","9","2","9","7","2"],["20","\u56fd\u9645","1","0","1","1","0"],["21","\u91cd\u673a","51","10","44","41","3"],["22","\u5317\u5efa\u6295","18","5","16","13","3"],["23","\u5357\u5efa\u6295","83","24","60","59","1"],["24","\u57ce\u6295","12","4","12","8","4"],["25","\u4ea4\u5efa","21","5","27","16","11"],["26","\u751f\u6001","31","0","31","31","0"],["27","\u897f\u5357","9","2","18","7","11"],["\u5408\u8ba1","","1795","544","1425","1251","174"]];
+ /**
+ * 集团主数据接口运行态。
+ * rows 直接供弹窗表格读取,summary 供集团上线率卡片读取;二者必须成对更新。
+ * loaded/error/loading 用于视图层展示“接口数据/兜底数据/加载中”的状态差异。
+ */
+ const projectMasterDataState = {
+ loading: false,
+ loaded: false,
+ error: "",
+ rows: usageReportRows,
+ summary: null,
+ updatedAt: ""
+ };
+ /**
+ * 单位一览表文件名映射。
+ * 弹窗“预览”按钮只通过单位简称查表,不直接拼猜文件名,避免单位顺序或文件命名变化时打开错误附件。
+ */
+ const usageReportFiles = {"\u516d\u5316\u5efa":"10\u3001\u516d\u5316\u5efa~\u4e3b\u6570\u636e\u9879\u76ee\u4e0eQHSE\u9879\u76ee\u4e00\u89c8\u88682026.6.3.xlsx","\u4e03\u5316\u5efa":"11\u3001\u4e03\u5316\u5efa~\u4e3b\u6570\u636e\u9879\u76ee\u4e0eQHSE\u9879\u76ee\u4e00\u89c8\u88682026.6.3.xlsx","\u4e5d\u5316\u5efa":"12\u3001\u4e5d\u5316\u5efa~\u4e3b\u6570\u636e\u9879\u76ee\u4e0eQHSE\u9879\u76ee\u4e00\u89c8\u88682026.6.3.xlsx","\u5341\u4e00\u5316\u5efa":"13\u3001\u5341\u4e00\u5316\u5efa~\u4e3b\u6570\u636e\u9879\u76ee\u4e0eQHSE\u9879\u76ee\u4e00\u89c8\u88682026.6.3.xlsx","\u5341\u4e09\u5316\u5efa":"14\u3001\u5341\u4e09\u5316\u5efa~\u4e3b\u6570\u636e\u9879\u76ee\u4e0eQHSE\u9879\u76ee\u4e00\u89c8\u88682026.6.3.xlsx","\u5341\u56db\u5316\u5efa":"15\u3001\u5341\u56db\u5316\u5efa~\u4e3b\u6570\u636e\u9879\u76ee\u4e0eQHSE\u9879\u76ee\u4e00\u89c8\u88682026.6.3.xlsx","\u5341\u516d\u5316\u5efa":"16\u3001\u5341\u516d\u5316\u5efa~\u4e3b\u6570\u636e\u9879\u76ee\u4e0eQHSE\u9879\u76ee\u4e00\u89c8\u88682026.6.3.xlsx","\u571f\u6728":"17\u3001\u571f\u6728~\u4e3b\u6570\u636e\u9879\u76ee\u4e0eQHSE\u9879\u76ee\u4e00\u89c8\u88682026.6.3.xlsx","\u88c5\u5907":"18\u3001\u88c5\u5907~\u4e3b\u6570\u636e\u9879\u76ee\u4e0eQHSE\u9879\u76ee\u4e00\u89c8\u88682026.6.3.xlsx","\u534e\u8c0a":"19\u3001\u534e\u8c0a~\u4e3b\u6570\u636e\u9879\u76ee\u4e0eQHSE\u9879\u76ee\u4e00\u89c8\u88682026.6.3.xlsx","\u5929\u8fb0":"1\u3001\u5929\u8fb0~\u4e3b\u6570\u636e\u9879\u76ee\u4e0eQHSE\u9879\u76ee\u4e00\u89c8\u88682026.6.3.xlsx","\u91cd\u673a":"21\u3001\u91cd\u673a~\u4e3b\u6570\u636e\u9879\u76ee\u4e0eQHSE\u9879\u76ee\u4e00\u89c8\u88682026.6.3.xlsx","\u5317\u5efa\u6295":"22\u3001\u5317\u5efa\u6295~\u4e3b\u6570\u636e\u9879\u76ee\u4e0eQHSE\u9879\u76ee\u4e00\u89c8\u88682026.6.3.xlsx","\u5357\u5efa\u6295":"23\u3001\u5357\u5efa\u6295~\u4e3b\u6570\u636e\u9879\u76ee\u4e0eQHSE\u9879\u76ee\u4e00\u89c8\u88682026.6.3.xlsx","\u57ce\u6295":"24\u3001\u57ce\u6295~\u4e3b\u6570\u636e\u9879\u76ee\u4e0eQHSE\u9879\u76ee\u4e00\u89c8\u88682026.6.3.xlsx","\u4ea4\u5efa":"25\u3001\u4ea4\u5efa~\u4e3b\u6570\u636e\u9879\u76ee\u4e0eQHSE\u9879\u76ee\u4e00\u89c8\u88682026.6.3.xlsx","\u897f\u5357":"27\u3001\u897f\u5357~\u4e3b\u6570\u636e\u9879\u76ee\u4e0eQHSE\u9879\u76ee\u4e00\u89c8\u88682026.6.3.xlsx","\u8d5b\u9f0e":"2\u3001\u8d5b\u9f0e~\u4e3b\u6570\u636e\u9879\u76ee\u4e0eQHSE\u9879\u76ee\u4e00\u89c8\u88682026.6.3.xlsx","\u4e1c\u534e":"3\u3001\u4e1c\u534e~\u4e3b\u6570\u636e\u9879\u76ee\u4e0eQHSE\u9879\u76ee\u4e00\u89c8\u88682026.6.3.xlsx","\u4e94\u73af":"4\u3001\u4e94\u73af~\u4e3b\u6570\u636e\u9879\u76ee\u4e0eQHSE\u9879\u76ee\u4e00\u89c8\u88682026.6.3.xlsx","\u534e\u9646":"5\u3001\u534e\u9646~\u4e3b\u6570\u636e\u9879\u76ee\u4e0eQHSE\u9879\u76ee\u4e00\u89c8\u88682026.6.3.xlsx","\u6210\u8fbe":"6\u3001\u6210\u8fbe~\u4e3b\u6570\u636e\u9879\u76ee\u4e0eQHSE\u9879\u76ee\u4e00\u89c8\u88682026.6.3.xlsx","\u4e8c\u5316\u5efa":"7\u3001\u4e8c\u5316\u5efa~\u4e3b\u6570\u636e\u9879\u76ee\u4e0eQHSE\u9879\u76ee\u4e00\u89c8\u88682026.6.3.xlsx","\u4e09\u5316\u5efa":"8\u3001\u4e09\u5316\u5efa~\u4e3b\u6570\u636e\u9879\u76ee\u4e0eQHSE\u9879\u76ee\u4e00\u89c8\u88682026.6.3.xlsx","\u56db\u5316\u5efa":"9\u3001\u56db\u5316\u5efa~\u4e3b\u6570\u636e\u9879\u76ee\u4e0eQHSE\u9879\u76ee\u4e00\u89c8\u88682026.6.3.xlsx"};
+
+ /**
+ * 图表和状态标签共用色板。
+ * 图表模块只引用这里的语义色,不在 SVG 字符串中硬编码业务颜色。
+ */
+ const COLORS = {
+ online: "#5d93ff",
+ usage: "#5ee1b5",
+ accuracy: "#ffc857",
+ warning: "#ff5c66",
+ cyan: "#49d6ff",
+ muted: "#91b8d0"
+ };
+
+ /**
+ * 企业级模拟基础数据。
+ * 企业/项目/指标多层页面都会从这里派生统计值;真实接口只接入集团主数据时,仍保留该数据支撑其他模块演示。
+ */
+ const enterprises = [
+ {
+ name: "天辰公司",
+ owner: "张海",
+ stats: {
+ all: { projectCount: 40, onlineRate: 92, usageRate: 84, accuracyRate: 88, warningProjects: 4, warningItems: 39 },
+ new: { projectCount: 7, onlineRate: 86, usageRate: 78, accuracyRate: 82, warningProjects: 2, warningItems: 8 }
+ },
+ trend: { online: [88, 89, 90, 90, 91, 92, 92], usage: [78, 79, 80, 81, 82, 83, 84], accuracy: [84, 84, 85, 86, 86, 87, 88] }
+ },
+ {
+ name: "赛鼎公司",
+ owner: "刘明",
+ stats: {
+ all: { projectCount: 28, onlineRate: 83, usageRate: 69, accuracyRate: 70, warningProjects: 6, warningItems: 39 },
+ new: { projectCount: 6, onlineRate: 72, usageRate: 58, accuracyRate: 63, warningProjects: 4, warningItems: 12 }
+ },
+ trend: { online: [80, 80, 81, 82, 82, 83, 83], usage: [64, 64, 65, 66, 67, 68, 69], accuracy: [73, 72, 72, 71, 70, 70, 70] }
+ },
+ {
+ name: "东华公司",
+ owner: "王斌",
+ stats: {
+ all: { projectCount: 32, onlineRate: 88, usageRate: 76, accuracyRate: 79, warningProjects: 5, warningItems: 39 },
+ new: { projectCount: 3, onlineRate: 85, usageRate: 67, accuracyRate: 72, warningProjects: 2, warningItems: 7 }
+ },
+ trend: { online: [85, 85, 86, 86, 87, 88, 88], usage: [72, 72, 73, 74, 75, 76, 76], accuracy: [78, 78, 78, 79, 79, 79, 79] }
+ },
+ {
+ name: "五环公司",
+ owner: "赵宁",
+ stats: {
+ all: { projectCount: 24, onlineRate: 86, usageRate: 72, accuracyRate: 75, warningProjects: 4, warningItems: 32 },
+ new: { projectCount: 0, onlineRate: 0, usageRate: 0, accuracyRate: 0, warningProjects: 0, warningItems: 0 }
+ },
+ trend: { online: [82, 83, 84, 85, 85, 86, 86], usage: [68, 69, 70, 70, 71, 72, 72], accuracy: [72, 73, 74, 74, 74, 75, 75] }
+ },
+ {
+ name: "华陆公司",
+ owner: "陈哲",
+ stats: {
+ all: { projectCount: 25, onlineRate: 91, usageRate: 81, accuracyRate: 86, warningProjects: 3, warningItems: 28 },
+ new: { projectCount: 2, onlineRate: 90, usageRate: 74, accuracyRate: 80, warningProjects: 1, warningItems: 4 }
+ },
+ trend: { online: [89, 89, 90, 90, 91, 91, 91], usage: [76, 77, 78, 79, 80, 80, 81], accuracy: [83, 84, 84, 85, 85, 86, 86] }
+ },
+ {
+ name: "成达公司",
+ owner: "李强",
+ stats: {
+ all: { projectCount: 13, onlineRate: 95, usageRate: 88, accuracyRate: 90, warningProjects: 1, warningItems: 16 },
+ new: { projectCount: 0, onlineRate: 0, usageRate: 0, accuracyRate: 0, warningProjects: 0, warningItems: 0 }
+ },
+ trend: { online: [93, 93, 94, 94, 95, 95, 95], usage: [84, 85, 86, 86, 87, 88, 88], accuracy: [88, 88, 89, 89, 90, 90, 90] }
+ },
+ {
+ name: "二建",
+ owner: "周亮",
+ stats: {
+ all: { projectCount: 112, onlineRate: 84, usageRate: 73, accuracyRate: 80, warningProjects: 9, warningItems: 42 },
+ new: { projectCount: 13, onlineRate: 79, usageRate: 66, accuracyRate: 70, warningProjects: 5, warningItems: 10 }
+ },
+ trend: { online: [82, 82, 83, 83, 84, 84, 84], usage: [68, 69, 70, 71, 72, 73, 73], accuracy: [78, 78, 79, 79, 80, 80, 80] }
+ },
+ {
+ name: "三建",
+ owner: "孙越",
+ stats: {
+ all: { projectCount: 130, onlineRate: 78, usageRate: 64, accuracyRate: 66, warningProjects: 14, warningItems: 48 },
+ new: { projectCount: 12, onlineRate: 75, usageRate: 58, accuracyRate: 61, warningProjects: 7, warningItems: 13 }
+ },
+ trend: { online: [75, 75, 76, 76, 77, 78, 78], usage: [61, 61, 62, 62, 63, 64, 64], accuracy: [68, 67, 67, 66, 66, 66, 66] }
+ },
+ {
+ name: "四建",
+ owner: "马骏",
+ stats: {
+ all: { projectCount: 71, onlineRate: 87, usageRate: 78, accuracyRate: 82, warningProjects: 6, warningItems: 24 },
+ new: { projectCount: 5, onlineRate: 82, usageRate: 74, accuracyRate: 76, warningProjects: 2, warningItems: 6 }
+ },
+ trend: { online: [85, 85, 86, 86, 87, 87, 87], usage: [74, 75, 76, 76, 77, 78, 78], accuracy: [79, 80, 80, 81, 81, 82, 82] }
+ },
+ {
+ name: "六建",
+ owner: "韩伟",
+ stats: {
+ all: { projectCount: 192, onlineRate: 84, usageRate: 61, accuracyRate: 60, warningProjects: 22, warningItems: 55 },
+ new: { projectCount: 22, onlineRate: 78, usageRate: 52, accuracyRate: 56, warningProjects: 10, warningItems: 18 }
+ },
+ trend: { online: [81, 82, 82, 83, 83, 84, 84], usage: [58, 58, 59, 60, 60, 61, 61], accuracy: [63, 62, 62, 61, 61, 60, 60] }
+ },
+ {
+ name: "十一建",
+ owner: "郑一",
+ stats: {
+ all: { projectCount: 62, onlineRate: 89, usageRate: 77, accuracyRate: 78, warningProjects: 5, warningItems: 21 },
+ new: { projectCount: 5, onlineRate: 84, usageRate: 70, accuracyRate: 70, warningProjects: 2, warningItems: 5 }
+ },
+ trend: { online: [86, 87, 87, 88, 88, 89, 89], usage: [73, 74, 75, 76, 76, 77, 77], accuracy: [76, 76, 77, 77, 78, 78, 78] }
+ },
+ {
+ name: "十四建",
+ owner: "沈涛",
+ stats: {
+ all: { projectCount: 64, onlineRate: 82, usageRate: 68, accuracyRate: 69, warningProjects: 8, warningItems: 35 },
+ new: { projectCount: 9, onlineRate: 76, usageRate: 57, accuracyRate: 62, warningProjects: 5, warningItems: 9 }
+ },
+ trend: { online: [80, 80, 81, 81, 82, 82, 82], usage: [65, 65, 66, 67, 67, 68, 68], accuracy: [72, 71, 70, 70, 69, 69, 69] }
+ },
+ {
+ name: "十六建",
+ owner: "蒋峰",
+ stats: {
+ all: { projectCount: 81, onlineRate: 85, usageRate: 71, accuracyRate: 73, warningProjects: 7, warningItems: 31 },
+ new: { projectCount: 6, onlineRate: 80, usageRate: 65, accuracyRate: 67, warningProjects: 3, warningItems: 6 }
+ },
+ trend: { online: [82, 83, 83, 84, 84, 85, 85], usage: [67, 68, 69, 69, 70, 71, 71], accuracy: [75, 74, 74, 73, 73, 73, 73] }
+ },
+ {
+ name: "装备科技",
+ owner: "何平",
+ stats: {
+ all: { projectCount: 1, onlineRate: 100, usageRate: 100, accuracyRate: 100, warningProjects: 0, warningItems: 2 },
+ new: { projectCount: 0, onlineRate: 0, usageRate: 0, accuracyRate: 0, warningProjects: 0, warningItems: 0 }
+ },
+ trend: { online: [100, 100, 100, 100, 100, 100, 100], usage: [100, 100, 100, 100, 100, 100, 100], accuracy: [100, 100, 100, 100, 100, 100, 100] }
+ },
+ {
+ name: "南投",
+ owner: "梁旭",
+ stats: {
+ all: { projectCount: 29, onlineRate: 74, usageRate: 55, accuracyRate: 66, warningProjects: 6, warningItems: 18 },
+ new: { projectCount: 2, onlineRate: 70, usageRate: 50, accuracyRate: 55, warningProjects: 1, warningItems: 3 }
+ },
+ trend: { online: [72, 72, 73, 73, 74, 74, 74], usage: [53, 53, 54, 54, 55, 55, 55], accuracy: [66, 66, 66, 66, 66, 66, 66] }
+ },
+ {
+ name: "交建",
+ owner: "秦毅",
+ stats: {
+ all: { projectCount: 26, onlineRate: 80, usageRate: 69, accuracyRate: 72, warningProjects: 4, warningItems: 17 },
+ new: { projectCount: 2, onlineRate: 75, usageRate: 60, accuracyRate: 62, warningProjects: 1, warningItems: 2 }
+ },
+ trend: { online: [78, 78, 79, 79, 80, 80, 80], usage: [66, 67, 67, 68, 68, 69, 69], accuracy: [71, 71, 72, 72, 72, 72, 72] }
+ },
+ {
+ name: "生态",
+ owner: "郭晨",
+ stats: {
+ all: { projectCount: 17, onlineRate: 76, usageRate: 62, accuracyRate: 64, warningProjects: 5, warningItems: 22 },
+ new: { projectCount: 1, onlineRate: 70, usageRate: 55, accuracyRate: 55, warningProjects: 1, warningItems: 3 }
+ },
+ trend: { online: [73, 74, 74, 75, 75, 76, 76], usage: [59, 60, 60, 61, 61, 62, 62], accuracy: [66, 65, 65, 64, 64, 64, 64] }
+ }
+ ];
+
+ /**
+ * 当前准确率口径尚未接入正式数据,统一置 0,避免不同层级出现不一致的模拟准确率。
+ */
+ enterprises.forEach((enterprise) => {
+ ["all", "new"].forEach((dimension) => {
+ enterprise.stats[dimension].accuracyRate = 0;
+ });
+ enterprise.trend.accuracy = [0, 0, 0, 0, 0, 0, 0];
+ });
+
+ /**
+ * 系统页签配置。
+ * URL 参数 type=1/2/3 会映射到 safety/quality/emergency,并影响可见页签与指标库。
+ */
+ const systemLabels = {
+ safety: "安全",
+ quality: "质量",
+ emergency: "应急"
+ };
+
+ const systemTypeMap = {
+ 1: "safety",
+ 2: "quality",
+ 3: "emergency"
+ };
+
+ /**
+ * 从 URL 解析外部系统允许展示的页签。
+ * 支持 type、types、system、systems,也兼容纯查询串写法;非法值会回退为全部页签。
+ */
+ function getAllowedSystemsFromUrl() {
+ const params = new URLSearchParams(window.location.search);
+ const rawValue = params.get("type") || params.get("types") || params.get("system") || params.get("systems") || window.location.search.replace(/^\?/, "");
+ if (!rawValue) return Object.values(systemTypeMap);
+ const allowed = rawValue
+ .split(/[,\s,]+/)
+ .map((item) => systemTypeMap[item.trim()])
+ .filter(Boolean);
+ return allowed.length ? [...new Set(allowed)] : Object.values(systemTypeMap);
+ }
+
+ const allowedSystems = getAllowedSystemsFromUrl();
+
+ /**
+ * 各业务系统的重点指标池。
+ * 集团页指标卡、指标分析面板、指标穿透弹窗都从当前系统对应数组取数。
+ */
+ const warningIndicatorMap = {
+ safety: [
+ { name: "专职安全管理人员(含分包)总数", projects: 150, enterprises: 18, usageRate: 72 },
+ { name: "特种作业人员数量", projects: 138, enterprises: 16, usageRate: 74 },
+ { name: "危大工程进行中个数", projects: 121, enterprises: 15, usageRate: 69 },
+ { name: "重大风险", projects: 116, enterprises: 14, usageRate: 76 },
+ { name: "较大风险", projects: 104, enterprises: 13, usageRate: 68 },
+ { name: "高风险作业许可进行中数量", projects: 98, enterprises: 12, usageRate: 71 },
+ { name: "专项培训", projects: 92, enterprises: 12, usageRate: 66 },
+ { name: "三级安全教育培训人次", projects: 86, enterprises: 11, usageRate: 70 }
+ ],
+ quality: [
+ { name: "项目质量管理人数", projects: 136, enterprises: 17, usageRate: 70 },
+ { name: "人员报验数", projects: 128, enterprises: 16, usageRate: 68 },
+ { name: "培训人次", projects: 112, enterprises: 15, usageRate: 73 },
+ { name: "技术交底人次", projects: 105, enterprises: 14, usageRate: 71 },
+ { name: "企业及分支机构检查数", projects: 96, enterprises: 13, usageRate: 65 },
+ { name: "项目质量检查数", projects: 91, enterprises: 12, usageRate: 69 },
+ { name: "施工方案企业审批总数", projects: 83, enterprises: 11, usageRate: 63 },
+ { name: "施工方案项目审批总数", projects: 80, enterprises: 11, usageRate: 66 }
+ ],
+ emergency: [
+ { name: "应急预案备案", projects: 118, enterprises: 16, usageRate: 69 },
+ { name: "应急演练记录", projects: 111, enterprises: 15, usageRate: 64 },
+ { name: "应急物资台账", projects: 103, enterprises: 14, usageRate: 67 },
+ { name: "应急队伍配置", projects: 96, enterprises: 13, usageRate: 70 },
+ { name: "重大风险告警", projects: 88, enterprises: 12, usageRate: 62 },
+ { name: "专项应急培训", projects: 81, enterprises: 11, usageRate: 66 },
+ { name: "应急联络人员", projects: 76, enterprises: 10, usageRate: 71 },
+ { name: "现场处置方案", projects: 72, enterprises: 10, usageRate: 63 },
+ { name: "应急响应记录", projects: 66, enterprises: 9, usageRate: 60 },
+ { name: "复盘整改闭环", projects: 61, enterprises: 8, usageRate: 58 }
+ ]
+ };
+
+ /**
+ * 项目明细页字段模板。
+ * buildIndicatorRows 会结合项目使用率和滞后天数生成“是否启用、三日值、是否预警”等派生字段。
+ */
+ const indicatorTemplates = [
+ { name: "预警项数", field: "warningCount", base: 48 },
+ { name: "参建人数", field: "participants", base: 215 },
+ { name: "在施危大工程数", field: "dangerWorks", base: 3 },
+ { name: "安全技术交底数", field: "safetyDisclosure", base: 69 },
+ { name: "三级安全教育培训数", field: "safetyTraining", base: 555 },
+ { name: "专项培训数", field: "specialTraining", base: 25 },
+ { name: "特种作业培训数", field: "specialWorkTraining", base: 3 },
+ { name: "项目总监人数", field: "directorCount", base: 1 },
+ { name: "项目专职人数", field: "fulltimeCount", base: 5 },
+ { name: "项目安全监护人数", field: "guardianCount", base: 2 },
+ { name: "在岗执业注册师", field: "registeredEngineer", base: 4 },
+ { name: "A证人员", field: "certA", base: 1 },
+ { name: "B证人员", field: "certB", base: 2 },
+ { name: "C证人员", field: "certC", base: 4 },
+ { name: "特种作业人员", field: "specialWorkers", base: 57 },
+ { name: "项目安全领导小组会议数", field: "safetyMeeting", base: 2 },
+ { name: "项目安全例会数", field: "regularMeeting", base: 21 },
+ { name: "项目负责人带班检查次数", field: "leaderInspection", base: 3 },
+ { name: "项目专项检查次数", field: "specialInspection", base: 46 },
+ { name: "项目专业检查次数", field: "professionInspection", base: 132 }
+ ];
+
+ /**
+ * 项目名称池。
+ * generateProjects 用企业简称加这些后缀生成稳定项目名,保证同一企业在不同渲染中项目顺序一致。
+ */
+ const projectNamePool = [
+ "精细化工材料项目",
+ "煤炭深加工基地项目",
+ "全场系统工程",
+ "新材料园区工程",
+ "装置检修项目",
+ "安全提升改造工程",
+ "数字化施工项目",
+ "生产运行保障项目",
+ "罐区扩能工程",
+ "绿色低碳改造项目",
+ "配套管廊工程",
+ "污水处理优化项目",
+ "储运系统升级项目",
+ "消防能力提升项目",
+ "基础设施完善项目",
+ "智能工地试点项目",
+ "综合楼建设项目",
+ "设备更新项目"
+ ];
+
+ /**
+ * 单页看板运行时状态。
+ * view 控制主区域层级,drillOpen/drillView 控制弹窗层级;事件模块只改 state,再统一触发 render。
+ */
+ const state = {
+ view: "group",
+ dimension: "all",
+ system: "safety",
+ enterpriseName: "",
+ projectId: "",
+ selectedIndicator: "",
+ drillOpen: false,
+ drillView: "trend",
+ drillEnterpriseName: "",
+ drillListType: ""
+ };
diff --git a/res/js/indexV2.core.js b/res/js/indexV2.core.js
new file mode 100644
index 0000000..2f4c93b
--- /dev/null
+++ b/res/js/indexV2.core.js
@@ -0,0 +1,469 @@
+ const app = document.getElementById("app");
+ /**
+ * 核心工具与数据加工模块。
+ * 这里不直接拼整页 HTML,主要负责 DOM 引用、通用格式化、接口请求、集团汇总和项目/指标派生数据。
+ */
+ const breadcrumbs = document.getElementById("breadcrumbs");
+ const screen = document.querySelector(".screen");
+ const drillModal = document.getElementById("drillModal");
+ const drillModalBody = document.getElementById("drillModalBody");
+ const drillModalTitle = document.getElementById("drillModalTitle");
+
+ /**
+ * 根据 URL 参数过滤系统页签。
+ * 如果当前 state.system 被过滤掉,会自动切到允许列表第一个系统,保证后续取指标池时有有效 key。
+ */
+ function applySystemVisibility() {
+ document.querySelectorAll("[data-system]").forEach((button) => {
+ const visible = allowedSystems.includes(button.dataset.system);
+ button.hidden = !visible;
+ });
+ if (!allowedSystems.includes(state.system)) {
+ state.system = allowedSystems[0] || "safety";
+ }
+ }
+
+ /**
+ * 按 1920x1080 设计稿等比缩放整屏。
+ * 缩放只改外层 transform,内部图表和布局仍按固定设计尺寸计算,避免大屏和小屏下比例漂移。
+ */
+ function scaleScreen() {
+ const designWidth = 1920;
+ const designHeight = 1080;
+ const viewportWidth = window.innerWidth;
+ const viewportHeight = window.innerHeight;
+ const scale = Math.min(viewportWidth / designWidth, viewportHeight / designHeight);
+ const left = Math.max((viewportWidth - designWidth * scale) / 2, 0);
+ const top = Math.max((viewportHeight - designHeight * scale) / 2, 0);
+ screen.style.transform = `translate(${left}px, ${top}px) scale(${scale})`;
+ }
+
+ function clamp(value, min, max) {
+ return Math.max(min, Math.min(max, value));
+ }
+
+ function fmtPercent(value) {
+ return `${Number(value).toFixed(1).replace(".0", "")}%`;
+ }
+
+ function fmtNumber(value) {
+ return Number(value).toLocaleString("zh-CN");
+ }
+
+ function toSafeNumber(value) {
+ const number = Number(value);
+ return Number.isFinite(number) ? number : 0;
+ }
+
+ function formatApiDate(value) {
+ if (!value) return "";
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return String(value).split("T")[0] || "";
+ const y = date.getFullYear();
+ const m = String(date.getMonth() + 1).padStart(2, "0");
+ const d = String(date.getDate()).padStart(2, "0");
+ return `${y}-${m}-${d}`;
+ }
+
+ /**
+ * 归一化集团主数据接口行。
+ * 后端字段目前是 PascalCase,这里兼容 camelCase,避免接口层大小写调整导致前端表格直接断掉。
+ */
+ function normalizeProjectMasterData(item, index) {
+ return {
+ sortIndex: toSafeNumber(item.SortIndex ?? item.sortIndex ?? index + 1),
+ name: item.Abbreviation || item.abbreviation || "",
+ id: item.Id || item.id || "",
+ unitId: item.UnitId || item.unitId || "",
+ reportDate: item.ReportDate || item.reportDate || "",
+ dataDate: item.DataDate || item.dataDate || "",
+ total: toSafeNumber(item.Total ?? item.total),
+ masterTotal: toSafeNumber(item.MasterTotal ?? item.masterTotal),
+ qhseTotal: toSafeNumber(item.QHSETotal ?? item.qhseTotal),
+ linkedTotal: toSafeNumber(item.ZhengChang ?? item.zhengChang),
+ pendingTotal: toSafeNumber(item.DaiJian ?? item.daiJian),
+ noNeedTotal: toSafeNumber(item.BuYong ?? item.buYong),
+ preProjectTotal: toSafeNumber(item.YuLiXiang ?? item.yuLiXiang)
+ };
+ }
+
+ /**
+ * 汇总接口返回的单位行。
+ * 结果同时服务集团上线率卡片和弹窗合计行,所以这里保留主数据、QHSE、申请不用、已关联等完整口径。
+ */
+ function summarizeProjectMasterData(rows) {
+ return rows.reduce((summary, row) => {
+ summary.masterProjectCount += row.masterTotal;
+ summary.noNeedProjectCount += row.noNeedTotal;
+ summary.qhseProjectCount += row.qhseTotal;
+ summary.linkedTotal += row.linkedTotal;
+ summary.pendingTotal += row.pendingTotal;
+ summary.preProjectTotal += row.preProjectTotal;
+ summary.total += row.total;
+ return summary;
+ }, {
+ masterProjectCount: 0,
+ noNeedProjectCount: 0,
+ qhseProjectCount: 0,
+ linkedTotal: 0,
+ pendingTotal: 0,
+ preProjectTotal: 0,
+ total: 0
+ });
+ }
+
+ /**
+ * 把接口对象转换为弹窗表格二维数组。
+ * 视图层按数组渲染表格,最后追加“合计”行,保持接口数据和本地兜底数据结构一致。
+ */
+ function buildProjectMasterRows(rows) {
+ const sortedRows = [...rows].sort((a, b) => a.sortIndex - b.sortIndex);
+ const bodyRows = sortedRows.map((row, index) => [
+ String(index + 1),
+ row.name,
+ String(row.masterTotal),
+ String(row.noNeedTotal),
+ String(row.qhseTotal),
+ String(row.linkedTotal),
+ String(row.pendingTotal),
+ String(row.preProjectTotal),
+ formatApiDate(row.reportDate)
+ ]);
+ const summary = summarizeProjectMasterData(sortedRows);
+ bodyRows.push([
+ "合计",
+ "",
+ String(summary.masterProjectCount),
+ String(summary.noNeedProjectCount),
+ String(summary.qhseProjectCount),
+ String(summary.linkedTotal),
+ String(summary.pendingTotal),
+ String(summary.preProjectTotal),
+ ""
+ ]);
+ return bodyRows;
+ }
+
+ /**
+ * 读取弹窗表格行。
+ * 接口尚未返回或加载失败时直接回退本地 usageReportRows,保证弹窗始终有可展示数据。
+ */
+ function getProjectMasterRows() {
+ return projectMasterDataState.rows && projectMasterDataState.rows.length
+ ? projectMasterDataState.rows
+ : usageReportRows;
+ }
+
+ /**
+ * 读取集团上线率卡片所需的三个核心数。
+ * 接口成功时优先用 summary;失败时从兜底表的“合计”行读取,兜底表缺失时再用固定默认值。
+ */
+ function getProjectMasterSummary() {
+ if (projectMasterDataState.summary) return projectMasterDataState.summary;
+ const totalRow = getProjectMasterRows().find((row) => row[0] === "合计") || [];
+ return {
+ masterProjectCount: toSafeNumber(totalRow[2]) || 1795,
+ noNeedProjectCount: toSafeNumber(totalRow[3]) || 544,
+ qhseProjectCount: toSafeNumber(totalRow[4]) || 1425
+ };
+ }
+
+ /**
+ * 从接口注册表生成可请求配置。
+ * 所有查询参数统一在这里拼接,调用方只传业务参数,避免重复处理空值和 URL 编码。
+ */
+ function getRegisteredApiConfig(name, params = {}) {
+ const api = apiRegistry[name];
+ if (!api) throw new Error(`未注册接口:${name}`);
+ const url = new URL(api.url);
+ Object.entries(params).forEach(([key, value]) => {
+ if (value !== undefined && value !== null && value !== "") {
+ url.searchParams.set(key, value);
+ }
+ });
+ return {
+ method: api.method || "GET",
+ url: url.toString(),
+ headers: api.headers || {}
+ };
+ }
+
+ /**
+ * 统一接口请求入口。
+ * 使用 fetch 直接请求已注册接口;如果浏览器跨域或后端异常,会抛给上层进入兜底流程。
+ */
+ async function requestRegisteredApi(name, params = {}) {
+ const config = getRegisteredApiConfig(name, params);
+ const response = await fetch(config.url, {
+ method: config.method,
+ headers: config.headers,
+ cache: "no-store"
+ });
+ if (!response.ok) {
+ throw new Error(`接口请求失败:${response.status}`);
+ }
+ return response.json();
+ }
+
+ /**
+ * 加载集团主数据在建项目分析接口。
+ * 成功后更新 projectMasterDataState.rows/summary 并触发整页重渲染;
+ * 失败时记录错误并恢复本地兜底数据,让主卡片和统计弹窗仍可用。
+ */
+ async function loadProjectMasterDataAnalysis(unitId = "") {
+ projectMasterDataState.loading = true;
+ projectMasterDataState.error = "";
+ try {
+ const result = await requestRegisteredApi("projectMasterDataAnalysis", { unitId });
+ if (toSafeNumber(result.code) !== 1 || !Array.isArray(result.data)) {
+ throw new Error(result.message || "接口返回数据格式异常");
+ }
+ const normalizedRows = result.data.map(normalizeProjectMasterData).filter((row) => row.name);
+ const summary = summarizeProjectMasterData(normalizedRows);
+ projectMasterDataState.rows = buildProjectMasterRows(normalizedRows);
+ projectMasterDataState.summary = {
+ masterProjectCount: summary.masterProjectCount,
+ noNeedProjectCount: summary.noNeedProjectCount,
+ qhseProjectCount: summary.qhseProjectCount
+ };
+ projectMasterDataState.loaded = true;
+ projectMasterDataState.updatedAt = new Date().toISOString();
+ } catch (error) {
+ projectMasterDataState.error = error.message || "接口请求失败";
+ projectMasterDataState.summary = null;
+ projectMasterDataState.rows = usageReportRows;
+ console.error("集团项目上线统计接口请求失败", error);
+ } finally {
+ projectMasterDataState.loading = false;
+ }
+ render();
+ }
+
+ function dimLabel() {
+ return state.dimension === "all" ? "所有项目" : "新开工项目";
+ }
+
+ function systemLabel() {
+ return systemLabels[state.system] || "安全";
+ }
+
+ function getWarningIndicators() {
+ return warningIndicatorMap[state.system] || warningIndicatorMap.safety;
+ }
+
+ function getSelectedIndicator() {
+ const indicators = getWarningIndicators();
+ return indicators.find((item) => item.name === state.selectedIndicator) || indicators[0];
+ }
+
+ function indicatorAccuracy(item) {
+ return item.accuracyRate ?? 0;
+ }
+
+ /**
+ * 生成“指标-企业”维度的穿透数据。
+ * 当前没有企业级指标接口时,用企业三率和指标基础使用率加权生成稳定样本,供排行和预警数量展示。
+ */
+ function buildIndicatorEnterpriseMetrics(indicator) {
+ return enterprises
+ .filter((enterprise) => enterprise.stats[state.dimension].projectCount > 0)
+ .map((enterprise, index) => {
+ const stats = enterprise.stats[state.dimension];
+ const usageRate = clamp(Math.round(indicator.usageRate * 0.52 + stats.usageRate * 0.48 - (index % 4) * 2 + (index % 3)), 0, 100);
+ const accuracyRate = 0;
+ const projectCount = stats.projectCount;
+ const warningProjects = Math.max(0, Math.round(projectCount * (100 - usageRate) / 100 * 0.24) + (index % 3));
+ return {
+ enterprise,
+ projectCount,
+ warningProjects,
+ usageRate,
+ accuracyRate
+ };
+ });
+ }
+
+ /**
+ * 生成“指标-项目”维度的穿透数据。
+ * 三日值是否不变会影响 warning 字段,指标弹窗据此判断项目是否进入预警列表。
+ */
+ function buildIndicatorProjectMetrics(indicator, enterprise) {
+ return generateProjects(enterprise, state.dimension).map((project, index) => {
+ const usageRate = clamp(Math.round(project.usageRate * 0.58 + indicator.usageRate * 0.42 - (index % 4) * 2), 0, 100);
+ const enabled = usageRate >= 58 && project.created;
+ const stable = enabled && (project.lagDays >= 3 || index % 4 === 0);
+ const todayValue = enabled ? Math.max(0, Math.round(indicator.projects * (usageRate / 100) * 0.18 + index * 2)) : "-";
+ const yesterdayValue = enabled ? (stable ? todayValue : Math.max(0, todayValue - 1 - (index % 2))) : "-";
+ const beforeValue = enabled ? (stable ? todayValue : Math.max(0, todayValue - 2 - (index % 3))) : "-";
+ return {
+ project,
+ enabled,
+ usageRate,
+ todayValue,
+ yesterdayValue,
+ beforeValue,
+ lastUpdate: project.lastUpdate,
+ warning: stable
+ };
+ });
+ }
+
+ function getEnterprise(name) {
+ return enterprises.find((item) => item.name === name) || enterprises[0];
+ }
+
+ function getStats(enterprise) {
+ return enterprise.stats[state.dimension];
+ }
+
+ /**
+ * 按项目数加权计算集团平均率。
+ * 企业项目量差异较大,不能直接对企业百分比做算术平均。
+ */
+ function weightedRate(key, list = enterprises) {
+ const totalProjects = list.reduce((sum, enterprise) => sum + enterprise.stats[state.dimension].projectCount, 0);
+ if (!totalProjects) return 0;
+ const weighted = list.reduce((sum, enterprise) => {
+ const stats = enterprise.stats[state.dimension];
+ return sum + stats.projectCount * stats[key];
+ }, 0);
+ return weighted / totalProjects;
+ }
+
+ /**
+ * 集团总览汇总口径。
+ * 上线率三个数来自主数据接口状态;使用率、准确率、预警项仍来自企业基础数据的加权派生。
+ */
+ function groupSummary() {
+ const active = enterprises.filter((enterprise) => enterprise.stats[state.dimension].projectCount > 0);
+ const projectMasterSummary = getProjectMasterSummary();
+ const masterProjectCount = projectMasterSummary.masterProjectCount;
+ const noNeedProjectCount = projectMasterSummary.noNeedProjectCount;
+ const qhseProjectCount = projectMasterSummary.qhseProjectCount;
+ return {
+ projectCount: masterProjectCount,
+ onlineExpected: masterProjectCount,
+ onlineActual: qhseProjectCount,
+ noNeedProjectCount,
+ onlineRate: masterProjectCount ? qhseProjectCount / masterProjectCount * 100 : 0,
+ usageRate: weightedRate("usageRate", active),
+ accuracyRate: weightedRate("accuracyRate", active),
+ warningProjects: active.reduce((sum, enterprise) => sum + enterprise.stats[state.dimension].warningProjects, 0),
+ warningItems: active.reduce((sum, enterprise) => sum + enterprise.stats[state.dimension].warningItems, 0),
+ riskEnterprises: active.filter((enterprise) => {
+ const stats = enterprise.stats[state.dimension];
+ return Math.min(stats.onlineRate, stats.usageRate, stats.accuracyRate) < 70 || stats.warningProjects >= 8;
+ }).length
+ };
+ }
+
+ /**
+ * 风险排序分值。
+ * 使用率和准确率权重较高,同时叠加预警项目数和预警项数,让列表优先暴露“用了但数据质量差”的单位。
+ */
+ function riskScore(stats) {
+ const weakness = (100 - stats.onlineRate) * 0.25 + (100 - stats.usageRate) * 0.38 + (100 - stats.accuracyRate) * 0.32;
+ return Math.round(weakness + stats.warningProjects * 1.4 + stats.warningItems * 0.12);
+ }
+
+ function riskLevel(score) {
+ if (score >= 38) return { text: "重点督办", cls: "red" };
+ if (score >= 26) return { text: "持续跟踪", cls: "orange" };
+ return { text: "状态稳定", cls: "green" };
+ }
+
+ /**
+ * 按企业和维度生成稳定的项目样本。
+ * 该函数是企业页、项目页、指标穿透的共同数据源,项目 id 必须稳定,否则点击下钻会找不到项目。
+ */
+ function generateProjects(enterprise, dimension = state.dimension) {
+ const stats = enterprise.stats[dimension];
+ if (stats.projectCount === 0) return [];
+ const visibleCount = Math.min(Math.max(stats.projectCount, 6), 18);
+ const projectCount = Math.min(visibleCount, dimension === "new" ? Math.max(stats.projectCount, 3) : visibleCount);
+ const projects = [];
+
+ for (let index = 0; index < projectCount; index += 1) {
+ const shift = ((index % 7) - 3) * 3;
+ const riskBias = index % 5 === 0 ? -12 : index % 4 === 0 ? -7 : index % 3 === 0 ? -3 : 4;
+ const onlineRate = clamp(stats.onlineRate + shift + (index % 2 ? 2 : -1), 45, 100);
+ const usageRate = clamp(stats.usageRate + shift + riskBias, 35, 100);
+ const accuracyRate = 0;
+ const warningCount = Math.max(0, Math.round((100 - Math.min(onlineRate, usageRate, accuracyRate)) / 7) + (index % 4));
+ const lagDays = accuracyRate < 65 ? 5 + (index % 3) : accuracyRate < 78 ? 3 + (index % 2) : index % 3;
+ const created = onlineRate >= 72 || index % 6 !== 0;
+ const id = `${enterprise.name}-${dimension}-${index}`;
+ const name = `${enterprise.name}${projectNamePool[index % projectNamePool.length]}`;
+ projects.push({
+ id,
+ unit: enterprise.name,
+ name,
+ isNew: dimension === "new" || index < enterprise.stats.new.projectCount,
+ onlineRate,
+ usageRate,
+ accuracyRate,
+ warningCount,
+ lagDays,
+ created,
+ owner: ["王工", "李工", "赵工", "陈工", "刘工", "孙工"][index % 6],
+ systemStatus: created ? "已上线" : "未建档",
+ startDate: dateMinus(dimension === "new" ? 30 + index * 4 : 180 + index * 9),
+ lastUpdate: dateMinus(lagDays),
+ risk: warningCount >= 7 || accuracyRate < 60 ? "高" : warningCount >= 4 || usageRate < 70 ? "中" : "低"
+ });
+ }
+ return projects;
+ }
+
+ function dateMinus(days) {
+ const base = new Date(`${TODAY}T00:00:00+08:00`);
+ base.setDate(base.getDate() - days);
+ const y = base.getFullYear();
+ const m = String(base.getMonth() + 1).padStart(2, "0");
+ const d = String(base.getDate()).padStart(2, "0");
+ return `${y}-${m}-${d}`;
+ }
+
+ function getProjectById(projectId) {
+ const enterprise = getEnterprise(state.enterpriseName);
+ return generateProjects(enterprise, state.dimension).find((project) => project.id === projectId) || generateProjects(enterprise, state.dimension)[0];
+ }
+
+ /**
+ * 生成项目明细页指标行。
+ * 根据项目使用率推导指标是否启用,再用最近三日值是否停滞判断是否预警。
+ */
+ function buildIndicatorRows(project) {
+ return indicatorTemplates.map((indicator, index) => {
+ const enabledThreshold = project.usageRate - (index % 4) * 5 + (index % 2) * 3;
+ const enabled = enabledThreshold >= 58;
+ const lagDays = enabled ? clamp(project.lagDays + (index % 5) - 2, 0, 8) : 0;
+ const currentValue = enabled ? Math.max(0, Math.round(indicator.base * (project.usageRate / 82) + index * 2 + (project.warningCount % 4))) : "-";
+ const isWarning = enabled && lagDays >= 3;
+ const todayValue = currentValue;
+ const yesterdayValue = enabled ? (isWarning ? currentValue : Math.max(0, Number(currentValue) - 1 - (index % 2))) : "-";
+ const beforeValue = enabled ? (isWarning ? currentValue : Math.max(0, Number(currentValue) - 2 - (index % 3))) : "-";
+ return {
+ name: indicator.name,
+ enabled,
+ todayValue,
+ yesterdayValue,
+ beforeValue,
+ lastUpdate: enabled ? dateMinus(lagDays) : "-",
+ lagDays: enabled ? lagDays : "-",
+ isWarning
+ };
+ });
+ }
+
+ function statusClassByRate(rate) {
+ if (rate >= 80) return "ok";
+ if (rate >= 65) return "warn";
+ return "bad";
+ }
+
+ function statusTextByRate(rate) {
+ if (rate >= 80) return "正常";
+ if (rate >= 65) return "关注";
+ return "异常";
+ }
diff --git a/res/js/indexV2.enterprise.js b/res/js/indexV2.enterprise.js
new file mode 100644
index 0000000..469b998
--- /dev/null
+++ b/res/js/indexV2.enterprise.js
@@ -0,0 +1,209 @@
+ /**
+ * 企业级页面渲染模块。
+ * 负责企业总览、企业指标分析、项目图表和项目清单弹窗,数据来源主要是 core 模块按企业派生的项目样本。
+ */
+ function renderEnterpriseView() {
+ const enterprise = getEnterprise(state.enterpriseName);
+ const stats = getStats(enterprise);
+ const projects = generateProjects(enterprise, state.dimension);
+ const indicators = buildEnterpriseIndicatorItems(getWarningIndicators(), projects, stats);
+
+ // 新开工维度下部分企业没有项目,单独展示空态,避免图表按空数组渲染出异常坐标。
+ if (!stats.projectCount) {
+ app.innerHTML = `
+
+
+ 当前企业在「${dimLabel()}」维度暂无数据。
+
+ `;
+ return;
+ }
+
+ app.innerHTML = `
+
+ ${miniMetric("项目数量", stats.projectCount, "个", `${enterprise.name} 当前${dimLabel()}项目数量`, "enterprise-project-list")}
+ ${miniMetric("上线率", fmtPercent(stats.onlineRate), "", "项目已建档并进入系统", "enterprise-offline-list")}
+ ${miniMetric("使用率", fmtPercent(stats.usageRate), "", "连续三天无更新视未使用")}
+ ${miniMetric("准确率", fmtPercent(stats.accuracyRate), "", "数据准确情况")}
+
+
+
+ ${renderFocusIndicators(indicators.slice(0, 8), 0, { highlightHigh: false })}
+
+
+
+
+
+
+
+ ${renderTrendChart({
+ online: enterprise.trend.online,
+ usage: enterprise.trend.usage,
+ accuracy: enterprise.trend.accuracy
+ }, 1840, 112)}
+
+
+
+
+
+ ${renderProjectRateChart(projects, { height: 210 })}
+ 企业层用于回答:本单位哪些项目未上线、哪些项目上线后未使用指标、哪些项目数据连续未更新。
+
+
+
+
+ ${renderEnterpriseIndicatorAnalysis(indicators)}
+
+
+ `;
+ }
+
+ /**
+ * 企业页顶部小指标卡。
+ * action 不为空时会写入 data-action,事件代理据此打开项目清单或未上线项目清单弹窗。
+ */
+ function miniMetric(label, value, unit, desc, action = "") {
+ return `
+
+ ${label}
+ ${value}${unit}
+ ${desc}
+
+ `;
+ }
+
+ /**
+ * 企业项目风险表。
+ * 每行带 data-project,点击后切换到项目明细页;表格主要用于从企业层快速定位异常项目。
+ */
+ function renderProjectTable(projectRiskList) {
+ return `
+
+
+
+
+ | 项目名称 |
+ 是否上线 |
+ 使用率 |
+ 准确率 |
+ 预警项 |
+ 最近更新 |
+ 风险 |
+
+
+
+ ${projectRiskList.map(({ project }) => `
+
+ | ${project.name} |
+ ${project.created ? '已上线' : '未上线'} |
+ ${fmtPercent(project.usageRate)} |
+ ${fmtPercent(project.accuracyRate)} |
+ ${project.warningCount} |
+ ${project.lastUpdate} |
+ ${riskPill(project.risk)} |
+
+ `).join("")}
+
+
+
+ `;
+ }
+
+ /**
+ * 企业项目清单弹窗。
+ * type=offline 时只展示未建档/未上线项目;其他情况展示当前维度下所有生成项目。
+ */
+ function renderEnterpriseProjectListModal(type) {
+ const enterprise = getEnterprise(state.enterpriseName);
+ const projects = generateProjects(enterprise, state.dimension);
+ const rows = type === "offline"
+ ? projects.filter((project) => !project.created)
+ : projects;
+ const title = type === "offline" ? "未上线项目清单" : "所有项目清单";
+ const emptyText = type === "offline" ? "当前企业暂无未上线项目。" : "当前企业暂无项目。";
+ if (!rows.length) {
+ return `
+
+ `;
+ }
+ return `
+
+
+
+
+
+
+ | 项目名称 |
+ 是否上线 |
+ 使用率 |
+ 准确率 |
+ 预警项 |
+ 最近更新 |
+ 责任人 |
+ 风险 |
+
+
+
+ ${rows.map((project) => `
+
+ | ${project.name} |
+ ${project.created ? '已上线' : '未上线'} |
+ ${fmtPercent(project.usageRate)} |
+ ${fmtPercent(project.accuracyRate)} |
+ ${project.warningCount} |
+ ${project.lastUpdate} |
+ ${project.owner} |
+ ${riskPill(project.risk)} |
+
+ `).join("")}
+
+
+
+
+ `;
+ }
diff --git a/res/js/indexV2.events.js b/res/js/indexV2.events.js
new file mode 100644
index 0000000..8db60c4
--- /dev/null
+++ b/res/js/indexV2.events.js
@@ -0,0 +1,177 @@
+ /**
+ * 全局事件模块。
+ * 页面大部分内容由模板字符串动态生成,因此统一使用事件代理监听 click,再按 data-* 属性更新 state 或打开外链。
+ */
+ document.addEventListener("click", (event) => {
+ // 维度切换会影响企业、项目、指标所有派生数据;项目页切换维度后回到企业页,防止旧项目 id 失效。
+ const dimButton = event.target.closest("[data-dim]");
+ if (dimButton) {
+ state.dimension = dimButton.dataset.dim;
+ if (state.view === "project") {
+ state.view = "enterprise";
+ state.projectId = "";
+ }
+ if (state.drillOpen && state.drillView === "project") {
+ state.drillView = "trend";
+ }
+ render();
+ return;
+ }
+
+ // 系统切换后需要校验当前选中指标是否仍属于新系统,不属于则切换到该系统第一个指标。
+ const systemButton = event.target.closest("[data-system]");
+ if (systemButton) {
+ if (!allowedSystems.includes(systemButton.dataset.system)) return;
+ state.system = systemButton.dataset.system;
+ if (!getWarningIndicators().some((item) => item.name === state.selectedIndicator)) {
+ state.selectedIndicator = getWarningIndicators()[0]?.name || "";
+ }
+ render();
+ return;
+ }
+
+ // 显式操作按钮:外链预览、弹窗开关、面包屑返回都通过 data-action 分发。
+ const action = event.target.closest("[data-action]");
+ if (action) {
+ if (action.dataset.action === "more-indicators") {
+ window.open(INDICATOR_MORE_URL, "_blank", "noopener,noreferrer");
+ return;
+ }
+ if (action.dataset.action === "enterprise-more-indicators") {
+ window.open(ENTERPRISE_INDICATOR_MORE_URL, "_blank", "noopener,noreferrer");
+ return;
+ }
+ if (action.dataset.action === "group-usage-report-preview") {
+ window.open(GROUP_USAGE_REPORT_URL, "_blank", "noopener,noreferrer");
+ return;
+ }
+ if (action.dataset.action === "preview-unit-report") {
+ const unit = action.dataset.unit || "";
+ const fileName = usageReportFiles[unit];
+ if (!fileName) {
+ window.alert(`${unit || "该单位"}未上传一览表`);
+ return;
+ }
+ const fileUrl = `${REPORT_FILE_BASE_URL}${encodeURIComponent(fileName)}`;
+ window.open(`${OFFICE_PREVIEW_BASE_URL}${encodeURIComponent(fileUrl)}`, "_blank", "noopener,noreferrer");
+ return;
+ }
+ if (action.dataset.action === "group-online-stats") {
+ state.drillOpen = true;
+ state.drillView = "groupOnlineStats";
+ state.drillEnterpriseName = "";
+ state.drillListType = "";
+ state.projectId = "";
+ renderDrillModal();
+ return;
+ }
+ if (action.dataset.action === "close-drill") {
+ state.drillOpen = false;
+ state.drillView = "trend";
+ state.drillEnterpriseName = "";
+ state.drillListType = "";
+ renderDrillModal();
+ return;
+ }
+ if (action.dataset.action === "enterprise-project-list") {
+ state.drillOpen = true;
+ state.drillView = "enterpriseProjectList";
+ state.drillListType = "all";
+ state.projectId = "";
+ renderDrillModal();
+ return;
+ }
+ if (action.dataset.action === "enterprise-offline-list") {
+ state.drillOpen = true;
+ state.drillView = "enterpriseProjectList";
+ state.drillListType = "offline";
+ state.projectId = "";
+ renderDrillModal();
+ return;
+ }
+ if (action.dataset.action === "indicator-trend") {
+ state.drillOpen = true;
+ state.drillView = "trend";
+ state.drillEnterpriseName = "";
+ state.projectId = "";
+ state.drillListType = "";
+ renderDrillModal();
+ return;
+ }
+ if (action.dataset.action === "group") {
+ state.view = "group";
+ state.enterpriseName = "";
+ state.projectId = "";
+ }
+ if (action.dataset.action === "enterprise") {
+ state.view = "enterprise";
+ state.enterpriseName = action.dataset.enterprise || state.enterpriseName;
+ state.projectId = "";
+ }
+ render();
+ return;
+ }
+
+ // 指标卡点击:集团页打开指标趋势,企业页打开该企业下的项目指标穿透。
+ const indicatorTarget = event.target.closest("[data-indicator]");
+ if (indicatorTarget) {
+ state.selectedIndicator = indicatorTarget.dataset.indicator;
+ state.drillOpen = true;
+ if (state.view === "enterprise") {
+ state.drillView = "project";
+ state.drillEnterpriseName = state.enterpriseName;
+ } else {
+ state.drillView = "trend";
+ state.drillEnterpriseName = "";
+ }
+ state.projectId = "";
+ renderDrillModal();
+ return;
+ }
+
+ // 指标排行中的企业点击:保留当前指标,切换穿透企业,弹窗内容更新为项目维度明细。
+ const indicatorEnterpriseTarget = event.target.closest("[data-indicator-enterprise]");
+ if (indicatorEnterpriseTarget) {
+ state.drillEnterpriseName = indicatorEnterpriseTarget.dataset.indicatorEnterprise;
+ state.drillOpen = true;
+ state.drillView = "project";
+ state.projectId = "";
+ renderDrillModal();
+ return;
+ }
+
+ // 企业点击:从集团图表、表格或面包屑进入企业看板。
+ const enterpriseTarget = event.target.closest("[data-enterprise]");
+ if (enterpriseTarget) {
+ state.view = "enterprise";
+ state.enterpriseName = enterpriseTarget.dataset.enterprise;
+ state.projectId = "";
+ render();
+ return;
+ }
+
+ // 项目点击:进入项目级看板,并关闭当前弹窗,避免弹窗遮挡项目详情。
+ const projectTarget = event.target.closest("[data-project]");
+ if (projectTarget) {
+ state.view = "project";
+ state.projectId = projectTarget.dataset.project;
+ state.drillOpen = false;
+ state.drillView = "trend";
+ state.drillListType = "";
+ render();
+ }
+ });
+
+ /**
+ * 初始化顺序:
+ * 先绑定缩放并渲染兜底数据,再异步加载集团主数据接口;
+ * 接口返回后 loadProjectMasterDataAnalysis 会再次 render,刷新集团上线率卡片和统计弹窗。
+ */
+ window.addEventListener("resize", scaleScreen);
+ if (window.visualViewport) {
+ window.visualViewport.addEventListener("resize", scaleScreen);
+ }
+
+ scaleScreen();
+ render();
+ loadProjectMasterDataAnalysis();
diff --git a/res/js/indexV2.group.js b/res/js/indexV2.group.js
new file mode 100644
index 0000000..cafce6e
--- /dev/null
+++ b/res/js/indexV2.group.js
@@ -0,0 +1,393 @@
+ function kpiCard(label, value, unit, desc, trend, widthPercent, tone = "up") {
+ /**
+ * 集团级页面渲染模块。
+ * 负责集团总览、集团上线率卡片、企业分析、指标分析和集团上线统计弹窗。
+ */
+ return `
+
+ ${trend}
+ ${label}
+ ${value}${unit}
+
+ ${desc}
+
+ `;
+ }
+
+ /**
+ * 集团项目上线率卡片。
+ * 数值来自 groupSummary,而 groupSummary 会优先读取 projectMasterDataState.summary;
+ * 接口失败时 desc 展示错误原因,同时数值回退到本地兜底汇总。
+ */
+ function onlineKpiCard(summary) {
+ const trendText = projectMasterDataState.loading ? "加载中" : `${summary.onlineRate.toFixed(2)}%`;
+ const statusText = projectMasterDataState.error ? `接口失败,已使用本地兜底数据:${projectMasterDataState.error}` : "QHSE使用项目数 / 申请不用项目数 / 主数据项目数";
+ return `
+
+ ${trendText}
+ 集团项目上线率
+ ${summary.onlineActual}/${summary.noNeedProjectCount}/${summary.onlineExpected}
+
+ ${statusText}
+
+ `;
+ }
+
+ /**
+ * 面包屑渲染。
+ * 根据 state.view 组合集团、企业、项目、指标穿透层级,并附带当前维度和系统,方便用户确认筛选上下文。
+ */
+ function renderBreadcrumbs() {
+ const parts = [`集团总览`];
+ if (state.view === "enterprise" || state.view === "project") {
+ parts.push(`›${state.enterpriseName}`);
+ }
+ if (state.view === "project") {
+ const project = getProjectById(state.projectId);
+ parts.push(`›${project ? project.name : "项目明细"}`);
+ }
+ if (state.view === "indicatorTrend" || state.view === "indicatorEnterprise" || state.view === "indicatorProject") {
+ const indicator = getSelectedIndicator();
+ parts.push(`›${indicator.name}`);
+ }
+ if (state.view === "indicatorProject") {
+ parts.push(`›${state.enterpriseName}`);
+ }
+ parts.push(`当前维度:${dimLabel()}`);
+ parts.push(`当前系统:${systemLabel()}`);
+ breadcrumbs.innerHTML = parts.join("");
+ }
+
+ /**
+ * 全局主渲染入口。
+ * 事件模块只改 state,然后调用 render;这里集中决定当前应该渲染哪个视图,避免分散刷新局部 DOM。
+ */
+ function render() {
+ applySystemVisibility();
+ document.querySelectorAll("[data-dim]").forEach((button) => {
+ button.classList.toggle("active", button.dataset.dim === state.dimension);
+ });
+ document.querySelectorAll("[data-system]").forEach((button) => {
+ button.classList.toggle("active", button.dataset.system === state.system);
+ });
+ renderBreadcrumbs();
+ if (state.view === "group") renderGroupView();
+ if (state.view === "enterprise") renderEnterpriseView();
+ if (state.view === "project") renderProjectView();
+ if (state.view === "indicatorTrend") app.innerHTML = renderIndicatorTrendView();
+ if (state.view === "indicatorProject") app.innerHTML = renderIndicatorProjectView();
+ renderDrillModal();
+ }
+
+ /**
+ * 集团总览主视图。
+ * 顶部卡片展示集团汇总,中部图表支持企业下钻,底部指标区支持指标穿透和集团报告预览。
+ */
+ function renderGroupView() {
+ const summary = groupSummary();
+ const indicators = getWarningIndicators();
+ const activeEnterprises = enterprises.filter((enterprise) => enterprise.stats[state.dimension].projectCount > 0);
+ const riskList = activeEnterprises
+ .map((enterprise) => ({ enterprise, stats: enterprise.stats[state.dimension], score: riskScore(enterprise.stats[state.dimension]) }))
+ .sort((a, b) => b.score - a.score);
+
+ app.innerHTML = `
+
+ ${onlineKpiCard(summary)}
+ ${kpiCard("项目指标使用率", fmtPercent(summary.usageRate), "", "连续三天无更新视为未使用,用于发现用了但数据不动。", "+0.8%", summary.usageRate)}
+ ${kpiCard("指标数据准确率", fmtPercent(summary.accuracyRate), "", "用于发现数据准不准。", "-0.6%", summary.accuracyRate, "down")}
+ ${kpiCard("预警项目数", fmtNumber(summary.warningProjects), "个", `当前触发上线、使用、准确性风险的项目数,预警项 ${fmtNumber(summary.warningItems)} 条。`, "", clamp(100 - summary.warningProjects, 0, 100), "down")}
+ ${kpiCard("预警企业", fmtNumber(summary.riskEnterprises), "家", "上线率、使用率、准确率任一维度触发预警的企业。", "督办", clamp(100 - summary.riskEnterprises * 10, 0, 100), "down")}
+
+
+
+ ${renderFocusIndicators(indicators.slice(0, 8), 0, { highlightHigh: false })}
+
+
+
+
+
+
+
+ ${renderGroupTrendChart(activeEnterprises)}
+
+
+
+
+
+
+ ${renderGroupedRateChart(activeEnterprises, "enterprise", { height: 132 })}
+
+
+
+
+
+
+
+
+ ${renderIndicatorAnalysis(indicators)}
+
+
+ `;
+ }
+
+ /**
+ * 顶部重点指标条。
+ * 每张指标卡都带 data-indicator,事件代理会据此打开指标趋势或企业内项目穿透弹窗。
+ */
+ function renderFocusIndicators(items, offset, options = {}) {
+ const highlightHigh = options.highlightHigh !== false;
+ return items.map((item, index) => `
+
+ ${item.name}
+
+ 指标使用率:${item.usageRate}%
+ 指标准确率:${indicatorAccuracy(item)}%
+
+
+ `).join("");
+ }
+
+ /**
+ * 指标分析中的迷你进度条。
+ * value 控制条宽,displayValue 控制右侧展示文案,便于同一组件同时展示百分比和项目数量。
+ */
+ function miniRate(label, value, color, displayValue = `${Math.round(value)}%`) {
+ return `
+
+ ${label}
+
+ ${displayValue}
+
+ `;
+ }
+
+ /**
+ * 集团指标分析列表。
+ * 以项目覆盖量、使用率、准确率三条迷你进度条展示指标风险,用于快速挑出需要穿透的指标。
+ */
+ function renderIndicatorAnalysis(indicators) {
+ const maxProjects = Math.max(...indicators.map((item) => item.projects), 1);
+ return `
+
+ ${indicators.slice(0, 8).map((item) => {
+ const level = item.usageRate < 65
+ ? { text: "重点督办", cls: "red" }
+ : item.usageRate < 72
+ ? { text: "持续跟踪", cls: "orange" }
+ : { text: "保持关注", cls: "green" };
+ return `
+
+
${item.name}
+
+ ${miniRate("项目", Math.round((item.projects / maxProjects) * 100), COLORS.online, item.projects)}
+ ${miniRate("使用", item.usageRate, COLORS.usage)}
+ ${miniRate("准确", indicatorAccuracy(item), COLORS.accuracy)}
+
+
${level.text}
+
+ `;
+ }).join("")}
+
+ `;
+ }
+
+ /**
+ * 企业页指标分析数据派生。
+ * 在集团指标基础上叠加当前企业使用率和项目数量,生成更贴近企业维度的指标预警项。
+ */
+ function buildEnterpriseIndicatorItems(indicators, projects, stats) {
+ const projectBase = Math.max(projects.length, 1);
+ return indicators.slice(0, 8).map((item, index) => {
+ const usageRate = clamp(Math.round(item.usageRate * 0.55 + stats.usageRate * 0.45 - (index % 3) * 2), 0, 100);
+ const projectsCount = clamp(Math.round(projectBase * (0.55 + (index % 4) * 0.08)), 1, projectBase);
+ const warningProjects = clamp(Math.round(projectsCount * (100 - usageRate) / 100 + (index % 2)), 0, projectsCount);
+ return {
+ ...item,
+ projects: projectsCount,
+ warningProjects,
+ usageRate,
+ accuracyRate: 0,
+ missingUpdate: warningProjects + (index % 3),
+ owner: ["安全管理部", "质量管理部", "项目管理部", "数据治理专员"][index % 4]
+ };
+ });
+ }
+
+ /**
+ * 企业指标分析列表。
+ * 与集团指标列表共用展示结构,但风险文案更偏整改和跟踪,用于企业页面的责任短板展示。
+ */
+ function renderEnterpriseIndicatorAnalysis(items) {
+ const maxProjects = Math.max(...items.map((item) => item.projects), 1);
+ return `
+
+ ${items.map((item) => {
+ const level = item.warningProjects >= 6 || item.usageRate < 65
+ ? { text: "重点整改", cls: "red" }
+ : item.warningProjects >= 3 || item.usageRate < 72
+ ? { text: "持续跟踪", cls: "orange" }
+ : { text: "保持关注", cls: "green" };
+ return `
+
+
${item.name}
+
+ ${miniRate("项目", Math.round((item.projects / maxProjects) * 100), COLORS.online, item.projects)}
+ ${miniRate("使用", item.usageRate, COLORS.usage)}
+ ${miniRate("准确", item.accuracyRate, COLORS.accuracy)}
+
+
${level.text}
+
+ `;
+ }).join("")}
+
+ `;
+ }
+
+ function renderEnterpriseTable(riskList) {
+ return `
+
+
+
+
+ | 企业 |
+ 项目数 |
+ 上线率 |
+ 使用率 |
+ 准确率 |
+ 预警项目 |
+ 责任人 |
+ 状态 |
+
+
+
+ ${riskList.map(({ enterprise, stats, score }) => {
+ const level = riskLevel(score);
+ return `
+
+ | ${enterprise.name} |
+ ${stats.projectCount} |
+ ${fmtPercent(stats.onlineRate)} |
+ ${fmtPercent(stats.usageRate)} |
+ ${fmtPercent(stats.accuracyRate)} |
+ ${stats.warningProjects} |
+ ${enterprise.owner} |
+ ${level.text} |
+
+ `;
+ }).join("")}
+
+
+
+ `;
+ }
+
+ function buildUsageReportRows(list) {
+ return list.map((enterprise) => {
+ const stats = enterprise.stats[state.dimension];
+ const usedProjects = Math.round(stats.projectCount * stats.usageRate / 100);
+ return {
+ name: enterprise.name,
+ expected: stats.projectCount,
+ used: usedProjects,
+ usageRate: stats.projectCount ? Math.round(usedProjects / stats.projectCount * 100) : 0,
+ trend: enterprise.trend.usage
+ };
+ });
+ }
+
+ /**
+ * 集团上线统计弹窗表格。
+ * 表格行统一从 getProjectMasterRows 读取:接口成功展示接口行,接口失败展示本地兜底行。
+ * 国际、生态单位在这里加 green-row,高亮规则集中在表格渲染层。
+ */
+ function renderUsageReportTable(list) {
+ const rows = getProjectMasterRows();
+ const headers = projectMasterDataState.loaded ? projectMasterDataHeaders : usageReportHeaders;
+ const statusText = projectMasterDataState.loading
+ ? `正在获取接口数据...
`
+ : projectMasterDataState.error
+ ? `接口请求失败,当前展示本地兜底数据:${projectMasterDataState.error}
`
+ : "";
+ return `
+ ${statusText}
+
+
+
+
+ ${headers.map((header) => `| ${header} | `).join("")}
+ 操作 |
+
+
+
+ ${rows.map((row) => {
+ const unit = row[1] || "";
+ const rowClass = [
+ row[0] === "合计" ? "summary-row" : "",
+ ["国际", "生态"].includes(unit) ? "green-row" : ""
+ ].filter(Boolean).join(" ");
+ return `
+
+ ${row.map((cell, index) => `| ${cell} | `).join("")}
+ ${unit && rowClass.indexOf("summary-row") === -1 ? `` : ""} |
+
+ `;
+ }).join("")}
+
+
+
+ `;
+ }
+
+ /**
+ * “集团项目上线统计”弹窗内容。
+ * 弹窗标题由 project 模块 renderDrillModal 设置,这里只负责面板主体和表格。
+ */
+ function renderGroupOnlineStatsModal() {
+ const activeEnterprises = enterprises.filter((enterprise) => enterprise.stats[state.dimension].projectCount > 0);
+ return `
+
+
+ ${renderUsageReportTable(activeEnterprises)}
+
+ `;
+ }
+
diff --git a/res/js/indexV2.indicators.js b/res/js/indexV2.indicators.js
new file mode 100644
index 0000000..7247e74
--- /dev/null
+++ b/res/js/indexV2.indicators.js
@@ -0,0 +1,265 @@
+ /**
+ * 指标穿透页面渲染模块。
+ * 包含集团维度的指标趋势、企业排行,以及企业维度的项目指标明细弹窗。
+ */
+ function renderIndicatorTrendView() {
+ const indicator = getSelectedIndicator();
+ const enterpriseMetrics = buildIndicatorEnterpriseMetrics(indicator);
+ const avgUsage = enterpriseMetrics.length
+ ? Math.round(enterpriseMetrics.reduce((sum, item) => sum + item.usageRate, 0) / enterpriseMetrics.length)
+ : 0;
+ const warningEnterprises = enterpriseMetrics.filter((item) => item.usageRate < 70 || item.accuracyRate === 0).length;
+ const positive = [...enterpriseMetrics].sort((a, b) => b.usageRate - a.usageRate).slice(0, 10);
+
+ return `
+
+
+ ${indicator.name}
+
+
+ 指标使用率
+ ${avgUsage}%
+
+
+ 指标准确率
+ 0%
+
+
+ 预警企业
+ ${warningEnterprises}家
+
+
+
+
+
+
+ ${renderIndicatorValueTrend(indicator)}
+
+
+
+
+
+ ${positive.map((item, index) => renderEnterpriseRankRow(item, index)).join("")}
+
+
+
+ `;
+ }
+
+ /**
+ * 企业下的指标项目穿透视图。
+ * 根据当前选中指标和 drillEnterpriseName 生成项目明细,用于定位哪些项目该指标停滞或未启用。
+ */
+ function renderIndicatorProjectView() {
+ const indicator = getSelectedIndicator();
+ const enterprise = getEnterprise(state.drillEnterpriseName || state.enterpriseName);
+ const projectMetrics = buildIndicatorProjectMetrics(indicator, enterprise);
+ const avgUsage = projectMetrics.length
+ ? Math.round(projectMetrics.reduce((sum, item) => sum + item.usageRate, 0) / projectMetrics.length)
+ : 0;
+ const warningCount = projectMetrics.filter((item) => item.warning).length;
+
+ return `
+
+
+ ${enterprise.name} · ${indicator.name}
+
+
+ 项目数量
+ ${projectMetrics.length}个
+
+
+ 平均使用率
+ ${avgUsage}%
+
+
+ 预警项目
+ ${warningCount}个
+
+
+
+
+
+ ${renderIndicatorProjectTable(projectMetrics)}
+
+ `;
+ }
+
+ /**
+ * 指标七日变化 SVG。
+ * 这里用当前指标覆盖项目数生成稳定趋势线,主要表达波动、停滞和异常断点的展示结构。
+ */
+ function renderIndicatorValueTrend(indicator) {
+ const width = 1180;
+ const height = 560;
+ const margin = { top: 28, right: 40, bottom: 62, left: 72 };
+ const chartWidth = width - margin.left - margin.right;
+ const chartHeight = height - margin.top - margin.bottom;
+ const dates = ["04-21", "04-22", "04-23", "04-24", "04-25", "04-26", "04-27"];
+ const base = indicator.projects;
+ const values = dates.map((_, index) => Math.max(0, Math.round(base * (0.82 + index * 0.025 + ((index % 3) - 1) * 0.035))));
+ const maxValue = Math.max(...values, 1);
+ const tickValues = [0, 0.25, 0.5, 0.75, 1].map((ratio) => Math.round(maxValue * ratio));
+ const points = values.map((value, index) => {
+ const x = margin.left + (chartWidth / (values.length - 1)) * index;
+ const y = margin.top + chartHeight - (value / maxValue) * chartHeight;
+ return `${x},${y}`;
+ }).join(" ");
+ const grid = tickValues.map((tick) => {
+ const y = margin.top + chartHeight - (tick / maxValue) * chartHeight;
+ return `${tick}`;
+ }).join("");
+ const circles = values.map((value, index) => {
+ const x = margin.left + (chartWidth / (values.length - 1)) * index;
+ const y = margin.top + chartHeight - (value / maxValue) * chartHeight;
+ return `${value}`;
+ }).join("");
+ const labels = dates.map((date, index) => {
+ const x = margin.left + (chartWidth / (dates.length - 1)) * index;
+ return `${date}`;
+ }).join("");
+
+ return `
+
+ `;
+ }
+
+ /**
+ * 指标企业排行行。
+ * clickable 为 true 时写入 data-indicator-enterprise,点击后进入该企业的项目维度指标明细。
+ */
+ function renderEnterpriseRankRow(item, index, clickable = true) {
+ return `
+
+
${index + 1}
+
+
${item.enterprise.name}
+
+ ${miniRate("使用", item.usageRate, COLORS.usage)}
+ ${miniRate("准确", item.accuracyRate, COLORS.accuracy)}
+
+
+
${item.usageRate}%
+
+ `;
+ }
+
+ /**
+ * 指标项目明细表。
+ * 今日、昨日、前日三列用于判断指标是否连续不变,warning 字段决定“是否预警”标签。
+ */
+ function renderIndicatorProjectTable(items) {
+ const beforeDay = dateMinus(2);
+ return `
+
+
+
+
+ | 项目名称 |
+ 是否启用 |
+ 今日(${TODAY}) |
+ 昨日(${PREVIOUS_DAY}) |
+ 前日(${beforeDay}) |
+ 最后更新 |
+ 是否预警 |
+
+
+
+ ${items.map((item) => `
+
+ | ${item.project.name} |
+ ${item.enabled ? '已启用' : '未启用'} |
+ ${item.todayValue} |
+ ${item.yesterdayValue} |
+ ${item.beforeValue} |
+ ${item.lastUpdate} |
+ ${item.warning ? '是' : '否'} |
+
+ `).join("")}
+
+
+
+ `;
+ }
+
+ /**
+ * 项目信息字段行。
+ * title 保留完整值,防止长项目名在窄列中被截断后无法查看全文。
+ */
+ function fieldRow(label, value) {
+ return `
+
+ `;
+ }
+
+ /**
+ * 风险标签。
+ * 将高/中/低或未启用状态映射成统一的颜色标签,供项目表格和项目清单复用。
+ */
+ function riskPill(risk) {
+ if (risk === "高" || risk === "未启用") return `${risk}`;
+ if (risk === "中") return `${risk}`;
+ return `${risk}`;
+ }
+
+ /**
+ * 项目级指标表。
+ * 与指标项目明细表结构相近,但数据来源是当前项目的 buildIndicatorRows 结果。
+ */
+ function renderIndicatorTable(rows) {
+ const beforeDay = dateMinus(2);
+ return `
+
+
+
+
+ | 指标名称 |
+ 是否启用 |
+ 今日(${TODAY}) |
+ 昨日(${PREVIOUS_DAY}) |
+ 前日(${beforeDay}) |
+ 最后更新 |
+ 是否预警 |
+
+
+
+ ${rows.map((row) => `
+
+ | ${row.name} |
+ ${row.enabled ? '已启用' : '未启用'} |
+ ${row.todayValue} |
+ ${row.yesterdayValue} |
+ ${row.beforeValue} |
+ ${row.lastUpdate} |
+ ${row.isWarning ? '是' : '否'} |
+
+ `).join("")}
+
+
+
+ `;
+ }
diff --git a/res/js/indexV2.project.js b/res/js/indexV2.project.js
new file mode 100644
index 0000000..941cbf9
--- /dev/null
+++ b/res/js/indexV2.project.js
@@ -0,0 +1,78 @@
+ /**
+ * 项目级页面渲染模块。
+ * 展示单个项目的上线状态、指标启用数量、准确数量和预警指标,并列出项目下所有治理指标。
+ */
+ function renderProjectView() {
+ const enterprise = getEnterprise(state.enterpriseName);
+ const project = getProjectById(state.projectId);
+ const rows = buildIndicatorRows(project);
+ const enabledCount = rows.filter((row) => row.enabled).length;
+ const accurateCount = rows.filter((row) => row.enabled && !row.isWarning).length;
+ const warningIndicatorCount = rows.filter((row) => row.isWarning).length;
+
+ app.innerHTML = `
+
+ ${miniMetric("项目上线状态", project.systemStatus, "", project.created ? "已创建项目档案并进入系统" : "未创建项目档案,需先完成上线")}
+ ${miniMetric("指标使用数量", `${enabledCount}/${rows.length}`, "", "项目内治理指标动没动")}
+ ${miniMetric("数据准确数量", `${accurateCount}/${rows.length}`, "", "数据准不准")}
+ ${miniMetric("预警指标", warningIndicatorCount, "项", "")}
+
+
+
+
+
+
+ ${fieldRow("所属企业", enterprise.name)}
+ ${fieldRow("项目名称", project.name)}
+ ${fieldRow("系统状态", project.systemStatus)}
+ ${fieldRow("开工日期", project.startDate)}
+ ${fieldRow("最近更新", project.lastUpdate)}
+
+
+
+
+
+ ${renderIndicatorTable(rows)}
+
+
+ `;
+ }
+
+ /**
+ * 统一弹窗渲染入口。
+ * state.drillView 决定展示集团上线统计、企业项目清单、指标项目穿透或指标趋势;
+ * 关闭弹窗时清空 body,避免旧内容残留到下一次打开。
+ */
+ function renderDrillModal() {
+ if (!state.drillOpen) {
+ drillModal.hidden = true;
+ drillModalBody.innerHTML = "";
+ return;
+ }
+ const indicator = getSelectedIndicator();
+ drillModal.hidden = false;
+ if (state.drillView === "groupOnlineStats") {
+ drillModalTitle.textContent = "集团项目上线统计";
+ drillModalBody.innerHTML = renderGroupOnlineStatsModal();
+ return;
+ }
+ if (state.drillView === "enterpriseProjectList") {
+ drillModalTitle.textContent = `${state.enterpriseName} · 项目清单`;
+ drillModalBody.innerHTML = renderEnterpriseProjectListModal(state.drillListType);
+ return;
+ }
+ drillModalTitle.textContent = `${indicator.name} · 指标穿透分析`;
+ if (state.drillView === "project") {
+ drillModalBody.innerHTML = renderIndicatorProjectView();
+ return;
+ }
+ drillModalBody.innerHTML = renderIndicatorTrendView();
+ }
diff --git a/res/report/10、六化建~主数据项目与QHSE项目一览表2026.6.3.xlsx b/res/report/10、六化建~主数据项目与QHSE项目一览表2026.6.3.xlsx
new file mode 100644
index 0000000..0248558
Binary files /dev/null and b/res/report/10、六化建~主数据项目与QHSE项目一览表2026.6.3.xlsx differ
diff --git a/res/report/11、七化建~主数据项目与QHSE项目一览表2026.6.3.xlsx b/res/report/11、七化建~主数据项目与QHSE项目一览表2026.6.3.xlsx
new file mode 100644
index 0000000..7ceaa30
Binary files /dev/null and b/res/report/11、七化建~主数据项目与QHSE项目一览表2026.6.3.xlsx differ
diff --git a/res/report/12、九化建~主数据项目与QHSE项目一览表2026.6.3.xlsx b/res/report/12、九化建~主数据项目与QHSE项目一览表2026.6.3.xlsx
new file mode 100644
index 0000000..d44bbc7
Binary files /dev/null and b/res/report/12、九化建~主数据项目与QHSE项目一览表2026.6.3.xlsx differ
diff --git a/res/report/13、十一化建~主数据项目与QHSE项目一览表2026.6.3.xlsx b/res/report/13、十一化建~主数据项目与QHSE项目一览表2026.6.3.xlsx
new file mode 100644
index 0000000..c864a25
Binary files /dev/null and b/res/report/13、十一化建~主数据项目与QHSE项目一览表2026.6.3.xlsx differ
diff --git a/res/report/14、十三化建~主数据项目与QHSE项目一览表2026.6.3.xlsx b/res/report/14、十三化建~主数据项目与QHSE项目一览表2026.6.3.xlsx
new file mode 100644
index 0000000..9d40a0b
Binary files /dev/null and b/res/report/14、十三化建~主数据项目与QHSE项目一览表2026.6.3.xlsx differ
diff --git a/res/report/15、十四化建~主数据项目与QHSE项目一览表2026.6.3.xlsx b/res/report/15、十四化建~主数据项目与QHSE项目一览表2026.6.3.xlsx
new file mode 100644
index 0000000..c067130
Binary files /dev/null and b/res/report/15、十四化建~主数据项目与QHSE项目一览表2026.6.3.xlsx differ
diff --git a/res/report/16、十六化建~主数据项目与QHSE项目一览表2026.6.3.xlsx b/res/report/16、十六化建~主数据项目与QHSE项目一览表2026.6.3.xlsx
new file mode 100644
index 0000000..e26d17b
Binary files /dev/null and b/res/report/16、十六化建~主数据项目与QHSE项目一览表2026.6.3.xlsx differ
diff --git a/res/report/17、土木~主数据项目与QHSE项目一览表2026.6.3.xlsx b/res/report/17、土木~主数据项目与QHSE项目一览表2026.6.3.xlsx
new file mode 100644
index 0000000..9f993f5
Binary files /dev/null and b/res/report/17、土木~主数据项目与QHSE项目一览表2026.6.3.xlsx differ
diff --git a/res/report/18、装备~主数据项目与QHSE项目一览表2026.6.3.xlsx b/res/report/18、装备~主数据项目与QHSE项目一览表2026.6.3.xlsx
new file mode 100644
index 0000000..3e04f24
Binary files /dev/null and b/res/report/18、装备~主数据项目与QHSE项目一览表2026.6.3.xlsx differ
diff --git a/res/report/19、华谊~主数据项目与QHSE项目一览表2026.6.3.xlsx b/res/report/19、华谊~主数据项目与QHSE项目一览表2026.6.3.xlsx
new file mode 100644
index 0000000..f815997
Binary files /dev/null and b/res/report/19、华谊~主数据项目与QHSE项目一览表2026.6.3.xlsx differ
diff --git a/res/report/1、天辰~主数据项目与QHSE项目一览表2026.6.3.xlsx b/res/report/1、天辰~主数据项目与QHSE项目一览表2026.6.3.xlsx
new file mode 100644
index 0000000..4da55f8
Binary files /dev/null and b/res/report/1、天辰~主数据项目与QHSE项目一览表2026.6.3.xlsx differ
diff --git a/res/report/21、重机~主数据项目与QHSE项目一览表2026.6.3.xlsx b/res/report/21、重机~主数据项目与QHSE项目一览表2026.6.3.xlsx
new file mode 100644
index 0000000..e7f51eb
Binary files /dev/null and b/res/report/21、重机~主数据项目与QHSE项目一览表2026.6.3.xlsx differ
diff --git a/res/report/22、北建投~主数据项目与QHSE项目一览表2026.6.3.xlsx b/res/report/22、北建投~主数据项目与QHSE项目一览表2026.6.3.xlsx
new file mode 100644
index 0000000..692ef13
Binary files /dev/null and b/res/report/22、北建投~主数据项目与QHSE项目一览表2026.6.3.xlsx differ
diff --git a/res/report/23、南建投~主数据项目与QHSE项目一览表2026.6.3.xlsx b/res/report/23、南建投~主数据项目与QHSE项目一览表2026.6.3.xlsx
new file mode 100644
index 0000000..e406cf4
Binary files /dev/null and b/res/report/23、南建投~主数据项目与QHSE项目一览表2026.6.3.xlsx differ
diff --git a/res/report/24、城投~主数据项目与QHSE项目一览表2026.6.3.xlsx b/res/report/24、城投~主数据项目与QHSE项目一览表2026.6.3.xlsx
new file mode 100644
index 0000000..bc3635d
Binary files /dev/null and b/res/report/24、城投~主数据项目与QHSE项目一览表2026.6.3.xlsx differ
diff --git a/res/report/25、交建~主数据项目与QHSE项目一览表2026.6.3.xlsx b/res/report/25、交建~主数据项目与QHSE项目一览表2026.6.3.xlsx
new file mode 100644
index 0000000..8e4c790
Binary files /dev/null and b/res/report/25、交建~主数据项目与QHSE项目一览表2026.6.3.xlsx differ
diff --git a/res/report/27、西南~主数据项目与QHSE项目一览表2026.6.3.xlsx b/res/report/27、西南~主数据项目与QHSE项目一览表2026.6.3.xlsx
new file mode 100644
index 0000000..af8d53c
Binary files /dev/null and b/res/report/27、西南~主数据项目与QHSE项目一览表2026.6.3.xlsx differ
diff --git a/res/report/2、赛鼎~主数据项目与QHSE项目一览表2026.6.3.xlsx b/res/report/2、赛鼎~主数据项目与QHSE项目一览表2026.6.3.xlsx
new file mode 100644
index 0000000..bd314fd
Binary files /dev/null and b/res/report/2、赛鼎~主数据项目与QHSE项目一览表2026.6.3.xlsx differ
diff --git a/res/report/3、东华~主数据项目与QHSE项目一览表2026.6.3.xlsx b/res/report/3、东华~主数据项目与QHSE项目一览表2026.6.3.xlsx
new file mode 100644
index 0000000..c66fb53
Binary files /dev/null and b/res/report/3、东华~主数据项目与QHSE项目一览表2026.6.3.xlsx differ
diff --git a/res/report/4、五环~主数据项目与QHSE项目一览表2026.6.3.xlsx b/res/report/4、五环~主数据项目与QHSE项目一览表2026.6.3.xlsx
new file mode 100644
index 0000000..1735adc
Binary files /dev/null and b/res/report/4、五环~主数据项目与QHSE项目一览表2026.6.3.xlsx differ
diff --git a/res/report/5、华陆~主数据项目与QHSE项目一览表2026.6.3.xlsx b/res/report/5、华陆~主数据项目与QHSE项目一览表2026.6.3.xlsx
new file mode 100644
index 0000000..772baa5
Binary files /dev/null and b/res/report/5、华陆~主数据项目与QHSE项目一览表2026.6.3.xlsx differ
diff --git a/res/report/6、成达~主数据项目与QHSE项目一览表2026.6.3.xlsx b/res/report/6、成达~主数据项目与QHSE项目一览表2026.6.3.xlsx
new file mode 100644
index 0000000..9d8a80a
Binary files /dev/null and b/res/report/6、成达~主数据项目与QHSE项目一览表2026.6.3.xlsx differ
diff --git a/res/report/7、二化建~主数据项目与QHSE项目一览表2026.6.3.xlsx b/res/report/7、二化建~主数据项目与QHSE项目一览表2026.6.3.xlsx
new file mode 100644
index 0000000..f49bebc
Binary files /dev/null and b/res/report/7、二化建~主数据项目与QHSE项目一览表2026.6.3.xlsx differ
diff --git a/res/report/8、三化建~主数据项目与QHSE项目一览表2026.6.3.xlsx b/res/report/8、三化建~主数据项目与QHSE项目一览表2026.6.3.xlsx
new file mode 100644
index 0000000..861aba7
Binary files /dev/null and b/res/report/8、三化建~主数据项目与QHSE项目一览表2026.6.3.xlsx differ
diff --git a/res/report/9、四化建~主数据项目与QHSE项目一览表2026.6.3.xlsx b/res/report/9、四化建~主数据项目与QHSE项目一览表2026.6.3.xlsx
new file mode 100644
index 0000000..c0bc5bb
Binary files /dev/null and b/res/report/9、四化建~主数据项目与QHSE项目一览表2026.6.3.xlsx differ
diff --git a/res/report/数据治理解决方案及数据分析报告.docx b/res/report/数据治理解决方案及数据分析报告.docx
new file mode 100644
index 0000000..691513b
Binary files /dev/null and b/res/report/数据治理解决方案及数据分析报告.docx differ
diff --git a/res/report/数据治理解决方案及数据分析报告.pdf b/res/report/数据治理解决方案及数据分析报告.pdf
new file mode 100644
index 0000000..72fbc8c
Binary files /dev/null and b/res/report/数据治理解决方案及数据分析报告.pdf differ