初始提交
This commit is contained in:
@@ -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 `<line class="grid-line" x1="${margin.left}" y1="${y}" x2="${width - margin.right}" y2="${y}"></line><text class="axis-label" x="${margin.left - 10}" y="${y + 4}" text-anchor="end">${tick}%</text>`;
|
||||
}).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 `
|
||||
<rect class="chart-click" data-enterprise="${enterprise.name}" x="${x}" y="${y}" width="${barWidth}" height="${barHeight}" rx="5" fill="${item.color}" opacity="0.92"></rect>
|
||||
<text class="bar-label" x="${x + barWidth / 2}" y="${Math.max(y - 7, 14)}" text-anchor="middle">${Math.round(value)}</text>
|
||||
`;
|
||||
}).join("");
|
||||
return `
|
||||
${rects}
|
||||
<text class="axis-label chart-click" data-enterprise="${enterprise.name}" x="${center}" y="${height - 20}" transform="rotate(-28 ${center} ${height - 20})" text-anchor="end">${label}</text>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
return `
|
||||
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="${mode === "enterprise" ? "企业三率对比图" : "三率对比图"}">
|
||||
${grid}
|
||||
<line class="axis" x1="${margin.left}" y1="${margin.top + chartHeight}" x2="${width - margin.right}" y2="${margin.top + chartHeight}"></line>
|
||||
${bars}
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目使用率/准确率柱状图。
|
||||
* 柱子用于进入项目明细,底部圆点表达项目是否已上线,便于在企业层同时查看使用和上线状态。
|
||||
*/
|
||||
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 `<line class="grid-line" x1="${margin.left}" y1="${y}" x2="${width - margin.right}" y2="${y}"></line><text class="axis-label" x="${margin.left - 10}" y="${y + 4}" text-anchor="end">${tick}%</text>`;
|
||||
}).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 `
|
||||
<rect class="chart-click" data-project="${project.id}" x="${x}" y="${y}" width="${barWidth}" height="${barHeight}" rx="5" fill="${item.color}" opacity="0.92"></rect>
|
||||
<text class="bar-label" x="${x + barWidth / 2}" y="${Math.max(y - 7, 14)}" text-anchor="middle">${Math.round(value)}</text>
|
||||
`;
|
||||
}).join("");
|
||||
const statusY = height - (height < 260 ? 48 : 78);
|
||||
const statusColor = project.created ? COLORS.cyan : COLORS.warning;
|
||||
const statusText = project.created ? "已上线" : "未上线";
|
||||
return `
|
||||
${rects}
|
||||
<circle class="chart-click" data-project="${project.id}" cx="${center - 18}" cy="${statusY}" r="5" fill="${statusColor}"></circle>
|
||||
<text class="axis-label chart-click" data-project="${project.id}" x="${center - 8}" y="${statusY + 4}" text-anchor="start">${statusText}</text>
|
||||
<text class="axis-label chart-click" data-project="${project.id}" x="${center}" y="${height - 24}" transform="rotate(-30 ${center} ${height - 24})" text-anchor="end">${shortName}</text>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
return `
|
||||
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="项目使用率与准确率对比图">
|
||||
${grid}
|
||||
<line class="axis" x1="${margin.left}" y1="${margin.top + chartHeight}" x2="${width - margin.right}" y2="${margin.top + chartHeight}"></line>
|
||||
${bars}
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 集团趋势加权平均。
|
||||
* 按企业项目数加权汇总各企业七日趋势,避免项目少的企业对集团曲线影响过大。
|
||||
*/
|
||||
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 `<line class="grid-line" x1="${margin.left}" y1="${y}" x2="${width - margin.right}" y2="${y}"></line><text class="axis-label" x="${margin.left - 8}" y="${y + 4}" text-anchor="end">${tick}%</text>`;
|
||||
}).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 `<circle cx="${x}" cy="${y}" r="4" fill="${item.color}"></circle>`;
|
||||
}).join("");
|
||||
return `<polyline points="${points}" fill="none" stroke="${item.color}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"></polyline>${circles}`;
|
||||
}).join("");
|
||||
const labels = dates.map((date, index) => {
|
||||
const x = margin.left + (chartWidth / (dates.length - 1)) * index;
|
||||
return `<text class="axis-label" x="${x}" y="${height - 10}" text-anchor="middle">${date}</text>`;
|
||||
}).join("");
|
||||
|
||||
return `
|
||||
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="三率趋势曲线">
|
||||
${grid}
|
||||
<line class="axis" x1="${margin.left}" y1="${margin.top + chartHeight}" x2="${width - margin.right}" y2="${margin.top + chartHeight}"></line>
|
||||
${lines}
|
||||
${labels}
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
@@ -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: ""
|
||||
};
|
||||
@@ -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 "异常";
|
||||
}
|
||||
@@ -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 = `
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2 class="panel-title">${enterprise.name} · ${dimLabel()}看板</h2>
|
||||
<div class="panel-note">当前维度暂无项目数据,可切换到“所有项目”查看企业总体情况。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="empty-state">当前企业在「${dimLabel()}」维度暂无数据。</div>
|
||||
</section>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
app.innerHTML = `
|
||||
<section class="metric-cards">
|
||||
${miniMetric("项目数量", stats.projectCount, "个", `${enterprise.name} 当前${dimLabel()}项目数量`, "enterprise-project-list")}
|
||||
${miniMetric("上线率", fmtPercent(stats.onlineRate), "", "项目已建档并进入系统", "enterprise-offline-list")}
|
||||
${miniMetric("使用率", fmtPercent(stats.usageRate), "", "连续三天无更新视未使用")}
|
||||
${miniMetric("准确率", fmtPercent(stats.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="enterprise-more-indicators">更多</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<article class="panel trend-panel enterprise-trend-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2 class="panel-title">企业三率趋势曲线</h2>
|
||||
<div class="panel-note">近 7 日${enterprise.name}上线率、使用率、准确率变化。</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">${renderTrendChart({
|
||||
online: enterprise.trend.online,
|
||||
usage: enterprise.trend.usage,
|
||||
accuracy: enterprise.trend.accuracy
|
||||
}, 1840, 112)}</div>
|
||||
</article>
|
||||
|
||||
<section class="dashboard-grid enterprise-overview">
|
||||
<article class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2 class="panel-title">${enterprise.name} · 项目分析</h2>
|
||||
<div class="panel-note">项目级不统计上线率,底部圆点表示是否上线;点击项目下钻到字段追踪。</div>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<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>
|
||||
<span class="legend-item"><span class="dot" style="background:${COLORS.cyan}"></span>已上线</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-box">${renderProjectRateChart(projects, { height: 210 })}</div>
|
||||
<div class="explain">企业层用于回答:本单位哪些项目未上线、哪些项目上线后未使用指标、哪些项目数据连续未更新。</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2 class="panel-title">指标分析</h2>
|
||||
<div class="panel-note">按${enterprise.name}指标预警项目数、使用率和数据状态展示责任短板。</div>
|
||||
</div>
|
||||
<div class="panel-tools">
|
||||
<button type="button" class="more-btn" data-action="enterprise-more-indicators">更多</button>
|
||||
</div>
|
||||
</div>
|
||||
${renderEnterpriseIndicatorAnalysis(indicators)}
|
||||
</article>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 企业页顶部小指标卡。
|
||||
* action 不为空时会写入 data-action,事件代理据此打开项目清单或未上线项目清单弹窗。
|
||||
*/
|
||||
function miniMetric(label, value, unit, desc, action = "") {
|
||||
return `
|
||||
<article class="mini-panel" ${action ? `data-action="${action}"` : ""}>
|
||||
<div class="mini-label">${label}</div>
|
||||
<div class="mini-value">${value}<span class="kpi-unit">${unit}</span></div>
|
||||
<div class="mini-desc">${desc}</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 企业项目风险表。
|
||||
* 每行带 data-project,点击后切换到项目明细页;表格主要用于从企业层快速定位异常项目。
|
||||
*/
|
||||
function renderProjectTable(projectRiskList) {
|
||||
return `
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>项目名称</th>
|
||||
<th>是否上线</th>
|
||||
<th>使用率</th>
|
||||
<th>准确率</th>
|
||||
<th>预警项</th>
|
||||
<th>最近更新</th>
|
||||
<th>风险</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${projectRiskList.map(({ project }) => `
|
||||
<tr class="clickable-row" data-project="${project.id}">
|
||||
<td>${project.name}</td>
|
||||
<td>${project.created ? '<span class="status-pill ok">已上线</span>' : '<span class="status-pill bad">未上线</span>'}</td>
|
||||
<td class="num">${fmtPercent(project.usageRate)}</td>
|
||||
<td class="num">${fmtPercent(project.accuracyRate)}</td>
|
||||
<td class="num">${project.warningCount}</td>
|
||||
<td>${project.lastUpdate}</td>
|
||||
<td>${riskPill(project.risk)}</td>
|
||||
</tr>
|
||||
`).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 企业项目清单弹窗。
|
||||
* 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 `
|
||||
<section class="panel drill-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2 class="panel-title">${enterprise.name} · ${title}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="empty-state">${emptyText}</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
return `
|
||||
<section class="panel drill-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2 class="panel-title">${enterprise.name} · ${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>
|
||||
<th>责任人</th>
|
||||
<th>风险</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows.map((project) => `
|
||||
<tr class="clickable-row" data-project="${project.id}">
|
||||
<td>${project.name}</td>
|
||||
<td>${project.created ? '<span class="status-pill ok">已上线</span>' : '<span class="status-pill bad">未上线</span>'}</td>
|
||||
<td class="num">${fmtPercent(project.usageRate)}</td>
|
||||
<td class="num">${fmtPercent(project.accuracyRate)}</td>
|
||||
<td class="num">${project.warningCount}</td>
|
||||
<td>${project.lastUpdate}</td>
|
||||
<td>${project.owner}</td>
|
||||
<td>${riskPill(project.risk)}</td>
|
||||
</tr>
|
||||
`).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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 `
|
||||
<section class="drill-hero">
|
||||
<article class="drill-title-card">
|
||||
<h2>${indicator.name}</h2>
|
||||
</article>
|
||||
<article class="drill-card">
|
||||
<div class="drill-card-label">指标使用率</div>
|
||||
<div class="drill-card-value">${avgUsage}%</div>
|
||||
</article>
|
||||
<article class="drill-card">
|
||||
<div class="drill-card-label">指标准确率</div>
|
||||
<div class="drill-card-value">0%</div>
|
||||
</article>
|
||||
<article class="drill-card">
|
||||
<div class="drill-card-label">预警企业</div>
|
||||
<div class="drill-card-value">${warningEnterprises}<span class="kpi-unit">家</span></div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="drill-grid">
|
||||
<article class="panel drill-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2 class="panel-title">指标变化曲线</h2>
|
||||
<div class="panel-note">X轴为日期,Y轴为该指标值,用于发现指标波动、停滞和异常断点。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-box">${renderIndicatorValueTrend(indicator)}</div>
|
||||
</article>
|
||||
|
||||
<article class="panel drill-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2 class="panel-title">指标企业排行</h2>
|
||||
<div class="panel-note">展示该指标使用较好的企业,点击企业进入项目级指标明细。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rank-list">
|
||||
${positive.map((item, index) => renderEnterpriseRankRow(item, index)).join("")}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 企业下的指标项目穿透视图。
|
||||
* 根据当前选中指标和 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 `
|
||||
<section class="drill-hero">
|
||||
<article class="drill-title-card">
|
||||
<h2>${enterprise.name} · ${indicator.name}</h2>
|
||||
</article>
|
||||
<article class="drill-card">
|
||||
<div class="drill-card-label">项目数量</div>
|
||||
<div class="drill-card-value">${projectMetrics.length}<span class="kpi-unit">个</span></div>
|
||||
</article>
|
||||
<article class="drill-card">
|
||||
<div class="drill-card-label">平均使用率</div>
|
||||
<div class="drill-card-value">${avgUsage}%</div>
|
||||
</article>
|
||||
<article class="drill-card">
|
||||
<div class="drill-card-label">预警项目</div>
|
||||
<div class="drill-card-value">${warningCount}<span class="kpi-unit">个</span></div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel drill-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2 class="panel-title">项目指标分析</h2>
|
||||
<div class="panel-note">三日数值未变化的项目指标判定为预警。</div>
|
||||
</div>
|
||||
</div>
|
||||
${renderIndicatorProjectTable(projectMetrics)}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 指标七日变化 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 `<line class="grid-line" x1="${margin.left}" y1="${y}" x2="${width - margin.right}" y2="${y}"></line><text class="axis-label" x="${margin.left - 10}" y="${y + 4}" text-anchor="end">${tick}</text>`;
|
||||
}).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 `<circle cx="${x}" cy="${y}" r="6" fill="${COLORS.cyan}"></circle><text class="point-label" x="${x}" y="${y - 12}" text-anchor="middle">${value}</text>`;
|
||||
}).join("");
|
||||
const labels = dates.map((date, index) => {
|
||||
const x = margin.left + (chartWidth / (dates.length - 1)) * index;
|
||||
return `<text class="axis-label" x="${x}" y="${height - 20}" text-anchor="middle">${date}</text>`;
|
||||
}).join("");
|
||||
|
||||
return `
|
||||
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="${indicator.name}指标变化曲线">
|
||||
${grid}
|
||||
<line class="axis" x1="${margin.left}" y1="${margin.top + chartHeight}" x2="${width - margin.right}" y2="${margin.top + chartHeight}"></line>
|
||||
<polyline points="${points}" fill="none" stroke="${COLORS.cyan}" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"></polyline>
|
||||
${circles}
|
||||
${labels}
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 指标企业排行行。
|
||||
* clickable 为 true 时写入 data-indicator-enterprise,点击后进入该企业的项目维度指标明细。
|
||||
*/
|
||||
function renderEnterpriseRankRow(item, index, clickable = true) {
|
||||
return `
|
||||
<div class="rank-row" ${clickable ? `data-indicator-enterprise="${item.enterprise.name}"` : ""}>
|
||||
<div class="rank-index">${index + 1}</div>
|
||||
<div>
|
||||
<div class="rank-name">${item.enterprise.name}</div>
|
||||
<div class="rank-bars">
|
||||
${miniRate("使用", item.usageRate, COLORS.usage)}
|
||||
${miniRate("准确", item.accuracyRate, COLORS.accuracy)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rank-value">${item.usageRate}%</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 指标项目明细表。
|
||||
* 今日、昨日、前日三列用于判断指标是否连续不变,warning 字段决定“是否预警”标签。
|
||||
*/
|
||||
function renderIndicatorProjectTable(items) {
|
||||
const beforeDay = dateMinus(2);
|
||||
return `
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>项目名称</th>
|
||||
<th>是否启用</th>
|
||||
<th>今日(${TODAY})</th>
|
||||
<th>昨日(${PREVIOUS_DAY})</th>
|
||||
<th>前日(${beforeDay})</th>
|
||||
<th>最后更新</th>
|
||||
<th>是否预警</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${items.map((item) => `
|
||||
<tr>
|
||||
<td>${item.project.name}</td>
|
||||
<td>${item.enabled ? '<span class="status-pill ok">已启用</span>' : '<span class="status-pill bad">未启用</span>'}</td>
|
||||
<td class="num">${item.todayValue}</td>
|
||||
<td class="num">${item.yesterdayValue}</td>
|
||||
<td class="num">${item.beforeValue}</td>
|
||||
<td>${item.lastUpdate}</td>
|
||||
<td>${item.warning ? '<span class="status-pill bad">是</span>' : '<span class="status-pill ok">否</span>'}</td>
|
||||
</tr>
|
||||
`).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目信息字段行。
|
||||
* title 保留完整值,防止长项目名在窄列中被截断后无法查看全文。
|
||||
*/
|
||||
function fieldRow(label, value) {
|
||||
return `
|
||||
<div class="field-row">
|
||||
<div class="field-name">${label}</div>
|
||||
<div class="field-value" title="${value}">${value}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 风险标签。
|
||||
* 将高/中/低或未启用状态映射成统一的颜色标签,供项目表格和项目清单复用。
|
||||
*/
|
||||
function riskPill(risk) {
|
||||
if (risk === "高" || risk === "未启用") return `<span class="status-pill bad">${risk}</span>`;
|
||||
if (risk === "中") return `<span class="status-pill warn">${risk}</span>`;
|
||||
return `<span class="status-pill ok">${risk}</span>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目级指标表。
|
||||
* 与指标项目明细表结构相近,但数据来源是当前项目的 buildIndicatorRows 结果。
|
||||
*/
|
||||
function renderIndicatorTable(rows) {
|
||||
const beforeDay = dateMinus(2);
|
||||
return `
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>指标名称</th>
|
||||
<th>是否启用</th>
|
||||
<th>今日(${TODAY})</th>
|
||||
<th>昨日(${PREVIOUS_DAY})</th>
|
||||
<th>前日(${beforeDay})</th>
|
||||
<th>最后更新</th>
|
||||
<th>是否预警</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows.map((row) => `
|
||||
<tr>
|
||||
<td>${row.name}</td>
|
||||
<td>${row.enabled ? '<span class="status-pill ok">已启用</span>' : '<span class="status-pill bad">未启用</span>'}</td>
|
||||
<td class="num">${row.todayValue}</td>
|
||||
<td class="num">${row.yesterdayValue}</td>
|
||||
<td class="num">${row.beforeValue}</td>
|
||||
<td>${row.lastUpdate}</td>
|
||||
<td>${row.isWarning ? '<span class="status-pill bad">是</span>' : '<span class="status-pill ok">否</span>'}</td>
|
||||
</tr>
|
||||
`).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -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 = `
|
||||
<section class="metric-cards">
|
||||
${miniMetric("项目上线状态", project.systemStatus, "", project.created ? "已创建项目档案并进入系统" : "未创建项目档案,需先完成上线")}
|
||||
${miniMetric("指标使用数量", `${enabledCount}/${rows.length}`, "", "项目内治理指标动没动")}
|
||||
${miniMetric("数据准确数量", `${accurateCount}/${rows.length}`, "", "数据准不准")}
|
||||
${miniMetric("预警指标", warningIndicatorCount, "项", "")}
|
||||
</section>
|
||||
|
||||
<section class="project-layout">
|
||||
<article class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2 class="panel-title">项目信息</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-list">
|
||||
${fieldRow("所属企业", enterprise.name)}
|
||||
${fieldRow("项目名称", project.name)}
|
||||
${fieldRow("系统状态", project.systemStatus)}
|
||||
${fieldRow("开工日期", project.startDate)}
|
||||
${fieldRow("最近更新", project.lastUpdate)}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2 class="panel-title">指标分析</h2>
|
||||
</div>
|
||||
</div>
|
||||
${renderIndicatorTable(rows)}
|
||||
</article>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一弹窗渲染入口。
|
||||
* 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();
|
||||
}
|
||||
Reference in New Issue
Block a user