初始提交
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
function kpiCard(label, value, unit, desc, trend, widthPercent, tone = "up") {
|
||||
/**
|
||||
* 集团级页面渲染模块。
|
||||
* 负责集团总览、集团上线率卡片、企业分析、指标分析和集团上线统计弹窗。
|
||||
*/
|
||||
return `
|
||||
<article class="kpi-card">
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 集团项目上线率卡片。
|
||||
* 数值来自 groupSummary,而 groupSummary 会优先读取 projectMasterDataState.summary;
|
||||
* 接口失败时 desc 展示错误原因,同时数值回退到本地兜底汇总。
|
||||
*/
|
||||
function onlineKpiCard(summary) {
|
||||
const trendText = projectMasterDataState.loading ? "加载中" : `${summary.onlineRate.toFixed(2)}%`;
|
||||
const statusText = projectMasterDataState.error ? `接口失败,已使用本地兜底数据:${projectMasterDataState.error}` : "QHSE使用项目数 / 申请不用项目数 / 主数据项目数";
|
||||
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="risk-band"><span style="width:${clamp(summary.onlineRate, 0, 100)}%"></span></div>
|
||||
<div class="kpi-desc">${statusText}</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 面包屑渲染。
|
||||
* 根据 state.view 组合集团、企业、项目、指标穿透层级,并附带当前维度和系统,方便用户确认筛选上下文。
|
||||
*/
|
||||
function renderBreadcrumbs() {
|
||||
const parts = [`<span class="crumb clickable" data-action="group">集团总览</span>`];
|
||||
if (state.view === "enterprise" || state.view === "project") {
|
||||
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>`);
|
||||
}
|
||||
if (state.view === "indicatorTrend" || state.view === "indicatorEnterprise" || state.view === "indicatorProject") {
|
||||
const indicator = getSelectedIndicator();
|
||||
parts.push(`<span>›</span><span class="crumb clickable" data-action="indicator-trend">${indicator.name}</span>`);
|
||||
}
|
||||
if (state.view === "indicatorProject") {
|
||||
parts.push(`<span>›</span><span class="crumb">${state.enterpriseName}</span>`);
|
||||
}
|
||||
parts.push(`<span class="crumb">当前维度:${dimLabel()}</span>`);
|
||||
parts.push(`<span class="crumb">当前系统:${systemLabel()}</span>`);
|
||||
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 = `
|
||||
<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")}
|
||||
</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">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2 class="panel-title">集团三率趋势曲线</h2>
|
||||
<div class="panel-note">近 7 日集团上线率、使用率、准确率变化。</div>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<span class="legend-item"><span class="dot" style="background:${COLORS.online}"></span>上线</span>
|
||||
<span class="legend-item"><span class="dot" style="background:${COLORS.usage}"></span>使用</span>
|
||||
<span class="legend-item"><span class="dot" style="background:${COLORS.accuracy}"></span>准确</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-box">${renderGroupTrendChart(activeEnterprises)}</div>
|
||||
</article>
|
||||
|
||||
<section class="group-bottom-grid">
|
||||
<div class="group-left-stack">
|
||||
<article class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2 class="panel-title">企业分析</h2>
|
||||
<div class="panel-note">按${dimLabel()}展示${systemLabel()}系统的企业上线率、使用率、准确率。点击企业柱子下钻到企业看板。</div>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<span class="legend-item"><span class="dot" style="background:${COLORS.online}"></span>上线率</span>
|
||||
<span class="legend-item"><span class="dot" style="background:${COLORS.usage}"></span>使用率</span>
|
||||
<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>
|
||||
</article>
|
||||
|
||||
<article class="panel usage-report-panel">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<article class="panel risk-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2 class="panel-title">指标分析</h2>
|
||||
</div>
|
||||
<div class="panel-tools">
|
||||
<button type="button" class="more-btn" data-action="more-indicators">更多</button>
|
||||
</div>
|
||||
</div>
|
||||
${renderIndicatorAnalysis(indicators)}
|
||||
</article>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶部重点指标条。
|
||||
* 每张指标卡都带 data-indicator,事件代理会据此打开指标趋势或企业内项目穿透弹窗。
|
||||
*/
|
||||
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}">
|
||||
<div class="warning-name">${item.name}</div>
|
||||
<div class="warning-lines">
|
||||
<span>指标使用率:<strong>${item.usageRate}%</strong></span>
|
||||
<span>指标准确率:<strong>${indicatorAccuracy(item)}%</strong></span>
|
||||
</div>
|
||||
</article>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* 指标分析中的迷你进度条。
|
||||
* value 控制条宽,displayValue 控制右侧展示文案,便于同一组件同时展示百分比和项目数量。
|
||||
*/
|
||||
function miniRate(label, value, color, displayValue = `${Math.round(value)}%`) {
|
||||
return `
|
||||
<div class="mini-bar">
|
||||
<span>${label}</span>
|
||||
<span class="mini-track"><span style="width:${value}%;background:${color}"></span></span>
|
||||
<strong>${displayValue}</strong>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 集团指标分析列表。
|
||||
* 以项目覆盖量、使用率、准确率三条迷你进度条展示指标风险,用于快速挑出需要穿透的指标。
|
||||
*/
|
||||
function renderIndicatorAnalysis(indicators) {
|
||||
const maxProjects = Math.max(...indicators.map((item) => item.projects), 1);
|
||||
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-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("")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 企业页指标分析数据派生。
|
||||
* 在集团指标基础上叠加当前企业使用率和项目数量,生成更贴近企业维度的指标预警项。
|
||||
*/
|
||||
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 `
|
||||
<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-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("")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderEnterpriseTable(riskList) {
|
||||
return `
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<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);
|
||||
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>
|
||||
`;
|
||||
}).join("")}
|
||||
</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
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 集团上线统计弹窗表格。
|
||||
* 表格行统一从 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>`
|
||||
: "";
|
||||
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>
|
||||
</tr>
|
||||
`;
|
||||
}).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* “集团项目上线统计”弹窗内容。
|
||||
* 弹窗标题由 project 模块 renderDrillModal 设置,这里只负责面板主体和表格。
|
||||
*/
|
||||
function renderGroupOnlineStatsModal() {
|
||||
const activeEnterprises = enterprises.filter((enterprise) => enterprise.stats[state.dimension].projectCount > 0);
|
||||
return `
|
||||
<section class="panel drill-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2 class="panel-title">集团项目上线统计</h2>
|
||||
</div>
|
||||
</div>
|
||||
${renderUsageReportTable(activeEnterprises)}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user