From 8a5a0d987782b2e29c3d7099e65252cf9b6990d3 Mon Sep 17 00:00:00 2001 From: augushong Date: Tue, 28 Jul 2026 06:34:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(nginx-log):=20NginxLogStat=20dashboard=20?= =?UTF-8?q?=E8=A7=86=E5=9B=BE=E4=B8=8E=20JS=EF=BC=88echarts=20=E8=B6=8B?= =?UTF-8?q?=E5=8A=BF=E5=9B=BE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../view/system/nginx_log_stat/_common.js | 3 + .../view/system/nginx_log_stat/index.html | 121 ++++++ .../admin/view/system/nginx_log_stat/index.js | 398 ++++++++++++++++++ 3 files changed, 522 insertions(+) create mode 100644 extend/base/admin/view/system/nginx_log_stat/_common.js create mode 100644 extend/base/admin/view/system/nginx_log_stat/index.html create mode 100644 extend/base/admin/view/system/nginx_log_stat/index.js diff --git a/extend/base/admin/view/system/nginx_log_stat/_common.js b/extend/base/admin/view/system/nginx_log_stat/_common.js new file mode 100644 index 0000000..bdf1790 --- /dev/null +++ b/extend/base/admin/view/system/nginx_log_stat/_common.js @@ -0,0 +1,3 @@ +// NginxLogStat dashboard _common.js +// 仪表盘页面不使用 ua.table.render(数据由 AJAX 预取后客户端渲染),故 init 对象为空。 +// 本文件仅用于满足 STRICT_VIEW_JS=true(AdminControllerBase::fetchJS 要求 _common 模板存在)。 diff --git a/extend/base/admin/view/system/nginx_log_stat/index.html b/extend/base/admin/view/system/nginx_log_stat/index.html new file mode 100644 index 0000000..8e2b463 --- /dev/null +++ b/extend/base/admin/view/system/nginx_log_stat/index.html @@ -0,0 +1,121 @@ +
+
+ + +
+
+
+ + + + +
+ + + ~ + + + +
+
+ + +
+
+
+
访问量 PV
+
+ - +
+
+
+
+
+
+ 独立访客 UV + +
+
+ - +
+
+
+
+
+
流量
+
+ - +
+
+
+
+
+
平均响应时间
+
+ - +
+
+
+
+ + +
+
+
+
状态码分布
+
+
+ +
+
+
+
+
+
PV / UV 趋势
+
+
+ +
+
+
+
+ + +
+
+
+
Top URL
+
+
+
+
+
+
+
+
Top Referer
+
+
+
+
+
+
+
+
UA 分布
+
+
+
+
+
+
+ +
+
+ + diff --git a/extend/base/admin/view/system/nginx_log_stat/index.js b/extend/base/admin/view/system/nginx_log_stat/index.js new file mode 100644 index 0000000..d1d5baf --- /dev/null +++ b/extend/base/admin/view/system/nginx_log_stat/index.js @@ -0,0 +1,398 @@ +/** + * NginxLogStat dashboard + * + * 依赖:echarts(_require.html 全局加载)、layui(table / laydate / util)、ua(admin-core.js) + * + * 后端 URL 不硬编码:用 ua.url('system.nginx_log_stat/index') 由 CONFIG.ADMIN 拼接。 + * AJAX 触发方式:URL 带 _ajax=1(不依赖 Accept header,命令行测试友好)。 + */ +layui.use(['table', 'laydate', 'util'], function () { + + var table = layui.table; + var laydate = layui.laydate; + var util = layui.util; + + // ── 配置(URL 不硬编码:由 ua.url 拼接 /{ADMIN}/system.nginx_log_stat/index)── + var STAT_INDEX_URL = ua.url('system.nginx_log_stat/index'); + + // ── DOM 引用 ── + var $dashboard = $('#nginxStatDashboard'); + var $periodGroup = $('#periodGroup'); + var $metricPv = $('#metricPv'); + var $metricUv = $('#metricUv'); + var $metricBytes = $('#metricBytes'); + var $metricAvgTime = $('#metricAvgTime'); + var $uvBadge = $('#uvEstimateBadge'); + var $statusPie = $('#statusPieChart'); + var $statusPieEmpty = $('#statusPieEmpty'); + var $trend = $('#trendChart'); + var $trendEmpty = $('#trendEmpty'); + + // ── echarts 实例(惰性初始化,容器存在时才创建)── + var statusPieChart = null; + var trendChart = null; + + // ── 状态:当前选中的 period ── + var currentPeriod = $dashboard.data('period') || 'day'; + + // ================================================================ + // 工具函数 + // ================================================================ + + /** + * 格式化字节数为人类可读(B / KB / MB / GB) + */ + function formatBytes(bytes) { + bytes = Number(bytes) || 0; + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(2) + ' MB'; + return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; + } + + /** + * 格式化响应时间(秒 → ms / s) + */ + function formatTime(seconds) { + seconds = Number(seconds) || 0; + if (seconds < 1) return Math.round(seconds * 1000) + ' ms'; + return seconds.toFixed(2) + ' s'; + } + + /** + * 数字千分位 + */ + function formatNumber(num) { + num = Number(num) || 0; + return util.number ? util.number(num) : num.toLocaleString(); + } + + /** + * 截断长文本(表格 label 列) + */ + function truncate(text, maxLen) { + text = String(text || ''); + maxLen = maxLen || 50; + if (text.length <= maxLen) return text; + return text.substring(0, maxLen) + '...'; + } + + // ================================================================ + // period 按钮切换 + // ================================================================ + + /** + * 设置 period 按钮高亮(active 用 layui-btn 实色,其余 layui-btn-primary) + */ + function setActivePeriod(period) { + currentPeriod = period; + $periodGroup.find('button[data-period]').each(function () { + var $btn = $(this); + if ($btn.data('period') === period) { + $btn.removeClass('layui-btn-primary'); + } else { + $btn.addClass('layui-btn-primary'); + } + }); + } + + $periodGroup.on('click', 'button[data-period]', function () { + var period = $(this).data('period'); + setActivePeriod(period); + loadData({ period: period }); + }); + + // ================================================================ + // 自定义日期区间 + // ================================================================ + + laydate.render({ + elem: '#dateStart', + type: 'date', + format: 'yyyy-MM-dd', + }); + laydate.render({ + elem: '#dateEnd', + type: 'date', + format: 'yyyy-MM-dd', + }); + + $('#customSearchBtn').on('click', function () { + var startVal = $('#dateStart').val(); + var endVal = $('#dateEnd').val(); + if (!startVal || !endVal) { + ua.msg.error('请选择开始和结束日期'); + return; + } + // 选了自定义区间后取消 period 按钮高亮 + setActivePeriod(''); + loadData({ period: 'custom', start: startVal, end: endVal }); + }); + + // ================================================================ + // AJAX 加载数据 + // ================================================================ + + /** + * 请求后端统计数据并重绘全部区块 + * + * @param {object} extra 额外参数(period / start / end) + */ + function loadData(extra) { + extra = extra || {}; + + var params = $.extend({ _ajax: 1, period: currentPeriod }, extra); + + ua.request.get({ + url: STAT_INDEX_URL + '?_ajax=1', + data: params, + statusName: 'code', + statusCode: 0, + }, function (res) { + // ok:code=0 + var data = (res && res.data) ? res.data : null; + if (!data) { + renderAll(buildEmptyData()); + return; + } + renderAll(data); + }, function (res) { + // no:code!=0 或业务错误 + var msg = (res && res.msg) ? res.msg : '加载统计数据失败'; + ua.msg.error(msg); + renderAll(buildEmptyData()); + }, function () { + // ex:网络异常等 + renderAll(buildEmptyData()); + }); + } + + function buildEmptyData() { + return { + summary: { + pv: 0, uv: 0, total_bytes: 0, + status_2xx: 0, status_3xx: 0, status_4xx: 0, status_5xx: 0, status_other: 0, + avg_request_time: 0, + }, + trend: [], + top_urls: [], + top_referers: [], + top_uas: [], + uv_is_estimate: false, + }; + } + + // ================================================================ + // 渲染:汇总入口 + // ================================================================ + + function renderAll(data) { + renderSummary(data.summary || {}, data.uv_is_estimate); + renderStatusPie(data.summary || {}); + renderTrend(data.trend || []); + renderTopTables({ + top_urls: data.top_urls || [], + top_referers: data.top_referers || [], + top_uas: data.top_uas || [], + }); + } + + // ================================================================ + // 渲染:指标卡片 + // ================================================================ + + function renderSummary(summary, uvIsEstimate) { + $metricPv.text(formatNumber(summary.pv)); + $metricUv.text(formatNumber(summary.uv)); + $metricBytes.text(formatBytes(summary.total_bytes)); + $metricAvgTime.text(formatTime(summary.avg_request_time)); + + // UV 近似角标 + if (uvIsEstimate) { + $uvBadge.show(); + } else { + $uvBadge.hide(); + } + } + + // ================================================================ + // 渲染:状态码饼图 + // ================================================================ + + function renderStatusPie(summary) { + var segments = [ + { name: '2xx', value: Number(summary.status_2xx) || 0, color: '#5FB878' }, + { name: '3xx', value: Number(summary.status_3xx) || 0, color: '#1E9FFF' }, + { name: '4xx', value: Number(summary.status_4xx) || 0, color: '#FFB800' }, + { name: '5xx', value: Number(summary.status_5xx) || 0, color: '#FF5722' }, + { name: '其他', value: Number(summary.status_other) || 0, color: '#999' }, + ]; + + var total = 0; + for (var i = 0; i < segments.length; i++) { + total += segments[i].value; + } + + // 空数据 → 隐藏 chart、显示占位 + if (total === 0) { + $statusPie.hide(); + $statusPieEmpty.show(); + return; + } + $statusPie.show(); + $statusPieEmpty.hide(); + + if (!statusPieChart) { + statusPieChart = echarts.init($statusPie[0]); + } + + statusPieChart.setOption({ + tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' }, + legend: { bottom: 0, left: 'center', data: segments.map(function (s) { return s.name; }) }, + series: [{ + name: '状态码', + type: 'pie', + radius: ['40%', '65%'], + center: ['50%', '45%'], + avoidLabelOverlap: true, + label: { show: true, formatter: '{b}\n{d}%' }, + labelLine: { show: true }, + data: segments.map(function (s) { + return { name: s.name, value: s.value, itemStyle: { color: s.color } }; + }), + }], + }); + } + + // ================================================================ + // 渲染:趋势折线图(PV/UV 双 Y 轴) + // ================================================================ + + function renderTrend(trend) { + var hasData = trend && trend.length > 0; + var pvSum = 0; + if (hasData) { + for (var i = 0; i < trend.length; i++) { + pvSum += Number(trend[i].pv) || 0; + } + } + + // 全部点 PV 都为 0 → 视为空数据 + if (!hasData || pvSum === 0) { + $trend.hide(); + $trendEmpty.show(); + return; + } + $trend.show(); + $trendEmpty.hide(); + + if (!trendChart) { + trendChart = echarts.init($trend[0]); + } + + var labels = trend.map(function (d) { return d.label; }); + var pvData = trend.map(function (d) { return Number(d.pv) || 0; }); + var uvData = trend.map(function (d) { return Number(d.uv) || 0; }); + + trendChart.setOption({ + tooltip: { trigger: 'axis' }, + legend: { data: ['PV', 'UV'], bottom: 0 }, + grid: { left: '3%', right: '4%', bottom: '12%', top: '8%', containLabel: true }, + xAxis: { type: 'category', boundaryGap: false, data: labels }, + yAxis: [ + { type: 'value', name: 'PV', position: 'left' }, + { type: 'value', name: 'UV', position: 'right' }, + ], + series: [ + { + name: 'PV', + type: 'line', + smooth: true, + symbol: 'circle', + symbolSize: 6, + itemStyle: { color: '#1E9FFF' }, + areaStyle: { opacity: 0.1 }, + data: pvData, + }, + { + name: 'UV', + type: 'line', + smooth: true, + yAxisIndex: 1, + symbol: 'circle', + symbolSize: 6, + itemStyle: { color: '#5FB878' }, + areaStyle: { opacity: 0.1 }, + data: uvData, + }, + ], + }, true); + } + + // ================================================================ + // 渲染:Top 表格(客户端数据,无分页) + // ================================================================ + + /** + * 通用 Top 表格渲染(客户端 data 模式) + * + * @param {string} elem 表格选择器 + * @param {string} filter lay-filter + * @param {array} rows 数据行 [{label,pv,uv}] + * @param {number} maxLabelLen label 截断长度 + */ + function renderTopTable(elem, filter, rows, maxLabelLen) { + // layui table 客户端渲染:传 data 数组(非 url) + table.render({ + elem: elem, + filter: filter, + data: rows || [], + page: false, + limit: 1000, + even: true, + text: { none: '暂无数据' }, + cols: [[ + { type: 'numbers', title: '#', width: 45 }, + { + field: 'label', title: '名称', minWidth: 120, + templet: function (d) { + var text = truncate(d.label, maxLabelLen || 50); + // 鼠标悬停显示完整内容 + if (d.label && String(d.label).length > (maxLabelLen || 50)) { + return '' + text + ''; + } + return text; + }, + }, + { field: 'pv', title: 'PV', width: 80, sort: true }, + { field: 'uv', title: 'UV', width: 80, sort: true }, + ]], + }); + } + + function renderTopTables(data) { + renderTopTable('#topUrlTable', 'topUrlTable', data.top_urls, 50); + renderTopTable('#topRefererTable', 'topRefererTable', data.top_referers, 40); + renderTopTable('#topUaTable', 'topUaTable', data.top_uas, 30); + } + + // ================================================================ + // 窗口 resize → echarts 自适应 + // ================================================================ + + $(window).on('resize', function () { + if (statusPieChart) statusPieChart.resize(); + if (trendChart) trendChart.resize(); + }); + + // ================================================================ + // 初始化 + // ================================================================ + + // 初始高亮当前 period 按钮(由控制器 assign 的 period 决定) + setActivePeriod(currentPeriod); + + // 首次加载数据 + loadData({ period: currentPeriod }); + +});