This commit is contained in:
2026-07-12 00:53:38 +08:00
parent d254377951
commit 25ae6f610c
14 changed files with 1208 additions and 835 deletions
+220 -137
View File
@@ -5,7 +5,19 @@
*/
return `
<article class="kpi-card">
<div class="kpi-trend ${tone === "down" ? "down" : ""}">${trend}</div>
${trend ? `<div class="kpi-trend ${tone === "down" ? "down" : ""}">${trend}</div>` : ""}
<div class="kpi-label">${label}</div>
<div class="kpi-value">${value}<span class="kpi-unit">${unit}</span></div>
<div class="risk-band"><span style="width:${clamp(widthPercent, 0, 100)}%"></span></div>
<div class="kpi-desc">${desc}</div>
</article>
`;
}
function clickableKpiCard(label, value, unit, desc, action, widthPercent, trend = "") {
return `
<article class="kpi-card chart-click" data-action="${action}">
${trend ? `<div class="kpi-trend">${trend}</div>` : ""}
<div class="kpi-label">${label}</div>
<div class="kpi-value">${value}<span class="kpi-unit">${unit}</span></div>
<div class="risk-band"><span style="width:${clamp(widthPercent, 0, 100)}%"></span></div>
@@ -16,17 +28,18 @@
/**
* 集团项目上线率卡片。
* 数值来自 groupSummary,而 groupSummary 会优先读取 projectMasterDataState.summary;
* 接口失败时 desc 展示错误原因,同时数值回退到本地兜底汇总。
* 数值只来自集团监控接口,接口失败时显示错误状态。
*/
function onlineKpiCard(summary) {
const trendText = projectMasterDataState.loading ? "加载中" : `${summary.onlineRate.toFixed(2)}%`;
const statusText = projectMasterDataState.error ? `接口失败,已使用本地兜底数据:${projectMasterDataState.error}` : "QHSE使用项目数 / 申请不用项目数 / 主数据项目数";
const trendText = projectMasterDataState.loading ? "加载中" : `${toSafeNumber(summary.onlineRate).toFixed(2)}%`;
const statusText = projectMasterDataState.error
? `接口请求失败:${projectMasterDataState.error}`
: "上线项目/申请不用/应使用项目/项目总数";
return `
<article class="kpi-card chart-click" data-action="group-online-stats">
<div class="kpi-trend">${trendText}</div>
<div class="kpi-label">集团项目上线率</div>
<div class="kpi-value">${summary.onlineActual}/${summary.noNeedProjectCount}/${summary.onlineExpected}</div>
<div class="kpi-value">${summary.proInfoText || `${summary.onlineActual}/${summary.noNeedProjectCount}/${summary.onlineExpected}`}</div>
<div class="risk-band"><span style="width:${clamp(summary.onlineRate, 0, 100)}%"></span></div>
<div class="kpi-desc">${statusText}</div>
</article>
@@ -43,8 +56,10 @@
parts.push(`<span>›</span><span class="crumb clickable" data-action="enterprise" data-enterprise="${state.enterpriseName}">${state.enterpriseName}</span>`);
}
if (state.view === "project") {
const project = getProjectById(state.projectId);
parts.push(`<span>›</span><span class="crumb">${project ? project.name : "项目明细"}</span>`);
const projectName = projectMonitoringState.data?.MetricProjectInfo?.ProjectShortName
|| projectMonitoringState.data?.MetricProjectInfo?.ProjectName
|| "项目明细";
parts.push(`<span>›</span><span class="crumb">${projectName}</span>`);
}
if (state.view === "indicatorTrend" || state.view === "indicatorEnterprise" || state.view === "indicatorProject") {
const indicator = getSelectedIndicator();
@@ -71,6 +86,7 @@
button.classList.toggle("active", button.dataset.system === state.system);
});
renderBreadcrumbs();
updateDataDateDisplay();
if (state.view === "group") renderGroupView();
if (state.view === "enterprise") renderEnterpriseView();
if (state.view === "project") renderProjectView();
@@ -86,25 +102,21 @@
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);
const analysisIndicators = projectMasterDataState.metricDatas.length ? projectMasterDataState.metricDatas : indicators;
const activeEnterprises = getGroupEnterpriseModels();
app.innerHTML = `
<section class="kpi-grid">
${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")}
${clickableKpiCard("预立项项目数", fmtNumber(summary.preProjectTotal), "个", "点击查看集团项目上线统计。", "group-online-stats", clamp(summary.preProjectTotal / 2, 0, 100))}
${clickableKpiCard("待建项目数", fmtNumber(summary.pendingTotal), "个", "点击查看集团项目上线统计。", "group-online-stats", clamp(summary.pendingTotal, 0, 100))}
${clickableKpiCard("未达标项目数", fmtNumber(summary.belowStandardProjects), "个", "指标使用率低于 80% 的项目。", "below-standard-projects", clamp(100 - summary.belowStandardProjects, 0, 100))}
${clickableKpiCard("项目指标使用率", fmtPercent(summary.usageRate), "", "点击查看单位项目指标使用情况。", "group-unit-metric-usages", summary.usageRate)}
${kpiCard("项目指标准确率", fmtPercent(summary.accuracyRate), "", "", "", summary.accuracyRate)}
</section>
<section class="indicator-strip">
${renderFocusIndicators(indicators.slice(0, 8), 0, { highlightHigh: false })}
<div class="indicator-more-cell">
<button type="button" class="more-btn" data-action="more-indicators">更多</button>
</div>
</section>
<article class="panel trend-panel group-trend-panel">
@@ -136,16 +148,16 @@
<span class="legend-item"><span class="dot" style="background:${COLORS.accuracy}"></span>准确率</span>
</div>
</div>
<div class="chart-box">${renderGroupedRateChart(activeEnterprises, "enterprise", { height: 132 })}</div>
<div class="chart-box">${activeEnterprises.length ? renderGroupedRateChart(activeEnterprises, "enterprise", { height: 132 }) : '<div class="empty-state compact-empty">暂无企业分析数据</div>'}</div>
</article>
<article class="panel usage-report-panel">
<article class="panel usage-report-panel chart-click" data-action="group-online-stats">
<div class="panel-header">
<div>
<h2 class="panel-title">集团在建项目使用分析报告</h2>
</div>
<div class="panel-tools">
<button type="button" class="more-btn" data-action="group-usage-report-preview">预览</button>
<button type="button" class="more-btn" data-action="group-online-stats">查看</button>
</div>
</div>
</article>
@@ -159,7 +171,7 @@
<button type="button" class="more-btn" data-action="more-indicators">更多</button>
</div>
</div>
${renderIndicatorAnalysis(indicators)}
${renderIndicatorAnalysis(analysisIndicators)}
</article>
</section>
`;
@@ -172,11 +184,11 @@
function renderFocusIndicators(items, offset, options = {}) {
const highlightHigh = options.highlightHigh !== false;
return items.map((item, index) => `
<article class="warning-card chart-click ${highlightHigh && offset + index < 2 ? "high" : ""}" title="${item.name}" data-indicator="${item.name}">
<article class="warning-card chart-click ${highlightHigh && offset + index < 2 ? "high" : ""}" title="${item.name}" data-indicator="${escapeHtmlAttr(item.name)}" data-indicator-field="${escapeHtmlAttr(resolveMetricField(item.name, item.field))}">
<div class="warning-name">${item.name}</div>
<div class="warning-lines">
<span>指标使用率:<strong>${item.usageRate}%</strong></span>
<span>指标准确率:<strong>${indicatorAccuracy(item)}%</strong></span>
<span>使用项目数:<strong>${item.projects}</strong></span>
</div>
</article>
`).join("");
@@ -205,20 +217,14 @@
return `
<div class="risk-summary">
${indicators.slice(0, 8).map((item) => {
const level = item.usageRate < 65
? { text: "重点督办", cls: "red" }
: item.usageRate < 72
? { text: "持续跟踪", cls: "orange" }
: { text: "保持关注", cls: "green" };
return `
<div class="risk-item" data-indicator="${item.name}">
<div class="risk-item" data-indicator="${escapeHtmlAttr(item.name)}" data-indicator-field="${escapeHtmlAttr(resolveMetricField(item.name, item.field))}">
<div class="risk-name">${item.name}</div>
<div class="risk-bars">
${miniRate("项目", Math.round((item.projects / maxProjects) * 100), COLORS.online, item.projects)}
${miniRate("使用", item.usageRate, COLORS.usage)}
${miniRate("准确", indicatorAccuracy(item), COLORS.accuracy)}
</div>
<span class="risk-badge ${level.cls}">${level.text}</span>
</div>
`;
}).join("")}
@@ -226,28 +232,6 @@
`;
}
/**
* 企业页指标分析数据派生。
* 在集团指标基础上叠加当前企业使用率和项目数量,生成更贴近企业维度的指标预警项。
*/
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]
};
});
}
/**
* 企业指标分析列表。
* 与集团指标列表共用展示结构,但风险文案更偏整改和跟踪,用于企业页面的责任短板展示。
@@ -257,20 +241,14 @@
return `
<div class="risk-summary">
${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 `
<div class="risk-item" data-indicator="${item.name}">
<div class="risk-item" data-indicator="${escapeHtmlAttr(item.name)}" data-indicator-field="${escapeHtmlAttr(resolveMetricField(item.name, item.field))}">
<div class="risk-name">${item.name}</div>
<div class="risk-bars">
${miniRate("项目", Math.round((item.projects / maxProjects) * 100), COLORS.online, item.projects)}
${miniRate("使用", item.usageRate, COLORS.usage)}
${miniRate("准确", item.accuracyRate, COLORS.accuracy)}
</div>
<span class="risk-badge ${level.cls}">${level.text}</span>
</div>
`;
}).join("")}
@@ -278,107 +256,212 @@
`;
}
function renderEnterpriseTable(riskList) {
/**
* 集团上线统计弹窗表格。
* 表格行统一从 getProjectMasterRecords 读取接口数据。
* 国际、生态单位在这里加 green-row,高亮规则集中在表格渲染层。
*/
function renderUsageReportTable(list) {
const rows = getProjectMasterRecords();
const summary = getProjectMasterSummary();
const statusText = projectMasterDataState.loading
? `<div class="table-status">正在获取接口数据...</div>`
: projectMasterDataState.error
? `<div class="table-status error">接口请求失败:${projectMasterDataState.error}</div>`
: "";
if (!rows.length) {
return `${statusText}<div class="empty-state">暂无集团项目上线统计数据</div>`;
}
const bodyRows = rows.map((row) => ({
unit: row.name,
cells: [
row.onlineAutoTotal,
row.onlineReportedTotal,
fmtFixedPercent(row.onlineRate || projectMasterOnlineRate(row), 2),
row.total,
row.linkedTotal,
row.preProjectTotal,
row.pendingTotal,
Math.max(row.total - row.linkedTotal - row.preProjectTotal - row.pendingTotal, 0),
row.noNeedTotal,
row.noNeedCompletedTotal,
row.noNeedStoppedTotal,
row.noNeedRestrictedTotal,
row.masterProjectCount
],
webUrl: resolveProjectMasterWebUrl(row)
}));
return `
${statusText}
<div class="table-wrap">
<table>
<table class="project-master-table">
<thead>
<tr>
<th>企业</th>
<th>项目数</th>
<th>上线率</th>
<th>使用率</th>
<th>准确率</th>
<th>预警项目</th>
<th>责任人</th>
<th>状态</th>
<th rowspan="2">企业简称</th>
<th colspan="2">上线项目数</th>
<th rowspan="2">上线率</th>
<th colspan="5">应使用项目数</th>
<th colspan="4">申请不用</th>
<th rowspan="2">主数据项目数</th>
</tr>
<tr>
<th>自动取值</th>
<th>定期填报</th>
<th>总数</th>
<th>已关联项目</th>
<th>预立项项目</th>
<th>待建项目</th>
<th>条件受限</th>
<th>总数</th>
<th>已完工</th>
<th>停缓建</th>
<th>条件受限</th>
</tr>
</thead>
<tbody>
${riskList.map(({ enterprise, stats, score }) => {
const level = riskLevel(score);
${bodyRows.map((row) => {
const unit = row.unit || "";
const rowClass = [
unit.includes("国际") || unit.includes("生态") ? "green-row" : ""
].filter(Boolean).join(" ");
const unitCell = /^https?:\/\//i.test(row.webUrl)
? `<a class="table-link" href="${escapeHtmlAttr(row.webUrl)}" target="_blank" rel="noopener noreferrer">${unit}</a>`
: unit;
return `
<tr class="clickable-row" data-enterprise="${enterprise.name}">
<td>${enterprise.name}</td>
<td class="num">${stats.projectCount}</td>
<td class="num">${fmtPercent(stats.onlineRate)}</td>
<td class="num">${fmtPercent(stats.usageRate)}</td>
<td class="num">${fmtPercent(stats.accuracyRate)}</td>
<td class="num">${stats.warningProjects}</td>
<td>${enterprise.owner}</td>
<td><span class="risk-badge ${level.cls}">${level.text}</span></td>
</tr>
`;
<tr class="${rowClass}">
<td>${unitCell}</td>
${row.cells.map((cell) => `<td class="num">${cell}</td>`).join("")}
</tr>
`;
}).join("")}
<tr class="summary-row">
<td>合计</td>
${["onlineAutoTotal", "onlineReportedTotal"].map((key) => `<td class="num">${rows.reduce((sum, row) => sum + toSafeNumber(row[key]), 0)}</td>`).join("")}
<td class="num">${fmtFixedPercent(summary.onlineRate, 2)}</td>
<td class="num">${rows.reduce((sum, row) => sum + toSafeNumber(row.total), 0)}</td>
<td class="num">${rows.reduce((sum, row) => sum + toSafeNumber(row.linkedTotal), 0)}</td>
<td class="num">${summary.preProjectTotal}</td>
<td class="num">${summary.pendingTotal}</td>
<td class="num">${rows.reduce((sum, row) => sum + Math.max(row.total - row.linkedTotal - row.preProjectTotal - row.pendingTotal, 0), 0)}</td>
<td class="num">${summary.noNeedProjectCount}</td>
<td class="num">${rows.reduce((sum, row) => sum + toSafeNumber(row.noNeedCompletedTotal), 0)}</td>
<td class="num">${rows.reduce((sum, row) => sum + toSafeNumber(row.noNeedStoppedTotal), 0)}</td>
<td class="num">${rows.reduce((sum, row) => sum + toSafeNumber(row.noNeedRestrictedTotal), 0)}</td>
<td class="num">${rows.reduce((sum, row) => sum + toSafeNumber(row.masterProjectCount), 0)}</td>
</tr>
</tbody>
</table>
</div>
`;
}
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
};
});
function renderBelowStandardProjectListModal() {
const enterprise = state.enterpriseName ? getEnterprise(state.enterpriseName) : null;
const rows = getBelowStandardProjects(enterprise);
const title = enterprise ? `${enterprise.name} · 未达标项目清单` : "集团未达标项目清单";
if (!rows.length) {
return `
<section class="panel drill-panel">
<div class="panel-header"><div><h2 class="panel-title">${title}</h2></div></div>
<div class="empty-state">当前暂无指标使用率低于 80% 的项目。</div>
</section>
`;
}
return `
<section class="panel drill-panel">
<div class="panel-header">
<div>
<h2 class="panel-title">${title}</h2>
<div class="panel-note">点击项目行进入项目级看板。</div>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>单位</th>
<th>项目名称</th>
<th>指标使用率</th>
<th>是否上线</th>
<th>最近更新</th>
<th>责任人</th>
</tr>
</thead>
<tbody>
${rows.map(({ enterprise: item, project }) => `
<tr class="clickable-row" data-project="${project.id}" data-project-unit-id="${escapeHtmlAttr(project.unitId || "")}" data-project-enterprise="${item.name}">
<td>${item.name}</td>
<td>${project.name}</td>
<td class="num">${fmtPercent(project.usageRate)}</td>
<td>${project.created ? '<span class="status-pill ok">已上线</span>' : '<span class="status-pill bad">未上线</span>'}</td>
<td>${project.lastUpdate}</td>
<td>${project.owner}</td>
</tr>
`).join("")}
</tbody>
</table>
</div>
</section>
`;
}
/**
* 集团上线统计弹窗表格。
* 表格行统一从 getProjectMasterRows 读取:接口成功展示接口行,接口失败展示本地兜底行。
* 国际、生态单位在这里加 green-row,高亮规则集中在表格渲染层。
*/
function renderUsageReportTable(list) {
const rows = getProjectMasterRows();
const headers = projectMasterDataState.loaded ? projectMasterDataHeaders : usageReportHeaders;
const statusText = projectMasterDataState.loading
? `<div class="table-status">正在获取接口数据...</div>`
: projectMasterDataState.error
? `<div class="table-status error">接口请求失败,当前展示本地兜底数据:${projectMasterDataState.error}</div>`
: "";
function renderUnitMetricUsageModal() {
const rows = getUnitMetricUsageRecords();
if (!rows.length) {
return `
<section class="panel drill-panel">
<div class="panel-header"><div><h2 class="panel-title">项目指标使用率</h2></div></div>
<div class="empty-state">当前暂无单位项目指标使用数据。</div>
</section>
`;
}
return `
${statusText}
<div class="table-wrap">
<table>
<thead>
<tr>
${headers.map((header) => `<th>${header}</th>`).join("")}
<th>操作</th>
</tr>
</thead>
<tbody>
${rows.map((row) => {
const unit = row[1] || "";
const rowClass = [
row[0] === "合计" ? "summary-row" : "",
["国际", "生态"].includes(unit) ? "green-row" : ""
].filter(Boolean).join(" ");
return `
<tr class="${rowClass}">
${row.map((cell, index) => `<td class="${index >= 2 ? "num" : ""}">${cell}</td>`).join("")}
<td>${unit && rowClass.indexOf("summary-row") === -1 ? `<button type="button" class="more-btn" data-action="preview-unit-report" data-unit="${unit}">预览</button>` : ""}</td>
<section class="panel drill-panel">
<div class="panel-header">
<div>
<h2 class="panel-title">项目指标使用率</h2>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>单位简称</th>
<th>QHSE在建项目数</th>
<th>活跃项目数</th>
<th>使用率</th>
<th>日期</th>
</tr>
`;
}).join("")}
</tbody>
</table>
</div>
</thead>
<tbody>
${rows.map((row) => {
const unitName = row.abbreviation || row.unitName || "";
const webUrl = resolveUnitMetricUsageWebUrl(row);
const unitCell = /^https?:\/\//i.test(webUrl)
? `<a class="table-link" href="${escapeHtmlAttr(webUrl)}" target="_blank" rel="noopener noreferrer">${unitName}</a>`
: unitName;
return `
<tr>
<td>${unitCell}</td>
<td class="num">${row.qhseTotal}</td>
<td class="num">${row.activeNum}</td>
<td class="num">${fmtPercent(row.usageRate)}</td>
<td>${formatApiDate(row.date)}</td>
</tr>
`;
}).join("")}
</tbody>
</table>
</div>
</section>
`;
}
/**
* “集团项目上线统计”弹窗内容。
* 弹窗标题由 project 模块 renderDrillModal 设置,这里只负责面板主体和表格。
* 弹窗标题由 core 模块 renderDrillModal 设置,这里只负责面板主体和表格。
*/
function renderGroupOnlineStatsModal() {
const activeEnterprises = enterprises.filter((enterprise) => enterprise.stats[state.dimension].projectCount > 0);
return `
<section class="panel drill-panel">
<div class="panel-header">
@@ -386,7 +469,7 @@
<h2 class="panel-title">集团项目上线统计</h2>
</div>
</div>
${renderUsageReportTable(activeEnterprises)}
${renderUsageReportTable()}
</section>
`;
}