diff --git a/gradle.properties b/gradle.properties index 94c83ea..f4240c3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,7 @@ minecraft_version=1.21.4 yarn_mappings=1.21.4+build.8 loader_version=0.16.10 # Mod Properties -mod_version=1.14.514.120 +mod_version=1.14.514.121 maven_group=org.example1 archives_base_name=playerOnlineTimeTrackerMod # Dependencies diff --git a/src/main/java/com/example/playertime/PlayerTimeMod.java b/src/main/java/com/example/playertime/PlayerTimeMod.java index 0097301..e259662 100644 --- a/src/main/java/com/example/playertime/PlayerTimeMod.java +++ b/src/main/java/com/example/playertime/PlayerTimeMod.java @@ -1,12 +1,26 @@ package com.example.playertime; +import com.mojang.brigadier.arguments.IntegerArgumentType; import net.fabricmc.api.ModInitializer; +import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.server.command.CommandManager; +import net.minecraft.server.command.ServerCommandSource; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.ClickEvent; +import net.minecraft.text.MutableText; +import net.minecraft.text.Text; +import net.minecraft.util.Util; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; + public class PlayerTimeMod implements ModInitializer { public static final Logger LOGGER = LoggerFactory.getLogger("PlayerTimeTracker"); private static PlayerTimeTracker timeTracker; @@ -60,6 +74,7 @@ public class PlayerTimeMod implements ModInitializer { LOGGER.error("[在线时间] Mod 初始化失败!", e); throw new RuntimeException("[在线时间] Mod 初始化失败", e); } + registerCommands(); } public static ModConfig getConfig() { @@ -71,4 +86,107 @@ public class PlayerTimeMod implements ModInitializer { public static PlayerTimeTracker getTimeTracker() { return timeTracker; } + + + public static void registerCommands() { + CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> { + dispatcher.register(CommandManager.literal("onlineTime") + .requires(source -> source.hasPermissionLevel(0)) + .executes(context -> showOnlineTime(context.getSource(), 1)) // 默认第一页 + .then(CommandManager.argument("page", IntegerArgumentType.integer(1)) + .executes(context -> showOnlineTime( + context.getSource(), + IntegerArgumentType.getInteger(context, "page") + )) + ) + ); + }); + } + + private static int showOnlineTime(ServerCommandSource source, int requestedPage) { + ServerPlayerEntity player = source.getPlayer(); + if (player == null) return 0; + + CompletableFuture.runAsync(() -> { + PlayerTimeTracker tracker = getTimeTracker(); + if (tracker != null) { + Map stats = tracker.getWhitelistedPlayerStats(); + + List sorted = stats.entrySet().stream() + .sorted((a, b) -> comparePlayTime(b.getValue(), a.getValue())) + .map(entry -> formatPlayerTime(entry.getKey(), entry.getValue())) + .collect(Collectors.toList()); + + sendPaginatedMessage(player, sorted, requestedPage); + } + }, Util.getMainWorkerExecutor()); + + return 1; + } + + + + private static int comparePlayTime(String a, String b) { + return Long.compare(parseTimeToSeconds(a), parseTimeToSeconds(b)); + } + + private static long parseTimeToSeconds(String timeStr) { + long seconds = 0; + String[] parts = timeStr.split(" "); + for (String part : parts) { + if (part.contains("h")) { + seconds += Integer.parseInt(part.replace("h", "")) * 3600; + } else if (part.contains("m")) { + seconds += Integer.parseInt(part.replace("m", "")) * 60; + } + } + return seconds; + } + + private static String formatPlayerTime(String name, String timeStr) { + return String.format("§e%s§r: %s", name, timeStr.split(" \\| ")[0]); // 只显示总时长 + } + + private static void sendPaginatedMessage(ServerPlayerEntity player, List lines, int page) { + int pageSize = 10; + int totalPages = (lines.size() + pageSize - 1) / pageSize; + page = Math.max(1, Math.min(page, totalPages)); + + int from = (page - 1) * pageSize; + int to = Math.min(from + pageSize, lines.size()); + + // 标题 + player.sendMessage(Text.literal("§6===== 玩家在线时长 (第 " + page + "/" + totalPages + " 页) ====="), false); + + // 内容 + for (int i = from; i < to; i++) { + player.sendMessage(Text.literal(lines.get(i)), false); + } + + // 翻页按钮 + MutableText footer = Text.literal(""); + if (page > 1) { + int finalPage = page; + footer.append(Text.literal("§a[上一页]") + .styled(style -> style.withClickEvent(new ClickEvent( + ClickEvent.Action.RUN_COMMAND, + "/onlineTime " + (finalPage - 1) + ))) + .append(" ")); + } + + footer.append(Text.literal("§7共 " + lines.size() + " 位玩家")); + + if (page < totalPages) { + int finalPage1 = page; + footer.append(" ").append(Text.literal("§a[下一页]") + .styled(style -> style.withClickEvent(new ClickEvent( + ClickEvent.Action.RUN_COMMAND, + "/onlineTime " + (finalPage1 + 1) + )))); + } + + player.sendMessage(footer, false); + } + } \ No newline at end of file diff --git a/src/main/java/com/example/playertime/WebServer.java b/src/main/java/com/example/playertime/WebServer.java index 9762c05..edc5728 100644 --- a/src/main/java/com/example/playertime/WebServer.java +++ b/src/main/java/com/example/playertime/WebServer.java @@ -345,7 +345,6 @@ public class WebServer { Runtime runtime = Runtime.getRuntime(); JsonObject status = new JsonObject(); - // 内存使用情况 long maxMemory = runtime.maxMemory(); long totalMemory = runtime.totalMemory(); long freeMemory = runtime.freeMemory(); diff --git a/src/main/resources/assets/playertime/web/css/style.css b/src/main/resources/assets/playertime/web/css/style.css index 3407cf7..f00309d 100644 --- a/src/main/resources/assets/playertime/web/css/style.css +++ b/src/main/resources/assets/playertime/web/css/style.css @@ -532,3 +532,93 @@ canvas { opacity: 1; } +.performance-metrics { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 15px; + margin-bottom: 15px; +} + +.metric { + background: var(--card-bg); + padding: 10px; + border-radius: 8px; + box-shadow: var(--shadow); +} + +.metric-label { + font-weight: bold; + display: block; + margin-bottom: 5px; + color: var(--primary-color-c); +} + +.metric-value { + font-size: 1.5rem; + font-family: monospace; +} + +@media (max-width: 600px) { + .performance-metrics { + grid-template-columns: 1fr; + } +} + + +.chart-container { + display: flex; + flex-direction: column; + gap: 15px; +} + +.chart-wrapper { + position: relative; + height: 300px; /* 固定高度 */ + width: 100%; +} + +.metric-display { + display: flex; + gap: 20px; + justify-content: center; +} + +.metric { + background: var(--card-bg); + padding: 12px 20px; + border-radius: 8px; + box-shadow: var(--shadow); + min-width: 120px; + text-align: center; +} + +.metric-label { + font-weight: bold; + display: block; + margin-bottom: 5px; + color: var(--primary-color-c); + font-size: 0.9rem; +} + +.metric-value { + font-size: 1.8rem; + font-family: monospace; + font-weight: bold; +} + +@media (max-width: 768px) { + .chart-wrapper { + height: 250px; + } + + .metric-display { + flex-direction: column; + gap: 10px; + } + + .metric { + padding: 10px 15px; + } +} + + diff --git a/src/main/resources/assets/playertime/web/index.html b/src/main/resources/assets/playertime/web/index.html index a0c037c..6196144 100644 --- a/src/main/resources/assets/playertime/web/index.html +++ b/src/main/resources/assets/playertime/web/index.html @@ -69,14 +69,26 @@
使用率: 0%
+
-

服务器性能

- -
-
平均Tick: 0 ms
-
运行时间: 0
+

实时性能

+
+
+
+ TPS: + 0.0 +
+
+ MSPT: + 0.0 +
+
+
+ +
+
diff --git a/src/main/resources/assets/playertime/web/js/app.js b/src/main/resources/assets/playertime/web/js/app.js index 69ab06b..cdc401a 100644 --- a/src/main/resources/assets/playertime/web/js/app.js +++ b/src/main/resources/assets/playertime/web/js/app.js @@ -1,5 +1,11 @@ document.addEventListener('DOMContentLoaded', function() { - // 获取DOM元素(带安全检查) + // 1. 首先初始化所有变量 + const tpsHistory = Array(30).fill(20); + const msptHistory = Array(30).fill(50); + let memoryChart = null; + let performanceChart = null; + + // 2. 获取DOM元素(带安全检查) const elements = { refreshBtn: document.getElementById('refresh-btn'), themeToggle: document.getElementById('theme-toggle'), @@ -13,34 +19,107 @@ document.addEventListener('DOMContentLoaded', function() { memoryFree: document.getElementById('memory-free'), memoryPercent: document.getElementById('memory-percent'), avgTick: document.getElementById('avg-tick'), - uptime: document.getElementById('uptime') + uptime: document.getElementById('uptime'), + tpsValue: document.getElementById('tps-value'), + msptValue: document.getElementById('mspt-value') }; - // 初始化主题 + // 3. 初始化主题 const savedTheme = localStorage.getItem('theme') || 'light'; document.documentElement.setAttribute('data-theme', savedTheme); - // 主题切换 + // 4. 初始化图表(必须在其他函数之前) + initCharts(); + + // 5. 事件监听器 if (elements.themeToggle) { elements.themeToggle.addEventListener('click', toggleTheme); } - // 刷新按钮 if (elements.refreshBtn) { elements.refreshBtn.addEventListener('click', handleRefresh); } - // 初始化图表 - const memoryChart = initMemoryChart(); - const performanceChart = initPerformanceChart(); - - // 初始加载数据 + // 6. 初始加载数据 loadAllData(); - // 设置定时刷新(10秒) - setInterval(loadAllData, 10000); + // 7. 设置定时刷新(10秒) + const refreshInterval = setInterval(loadAllData, 10000); /*** 功能函数 ***/ + function initCharts() { + // 内存图表 + const memoryCtx = document.getElementById('memory-chart')?.getContext('2d'); + if (memoryCtx) { + memoryChart = new Chart(memoryCtx, { + type: 'doughnut', + data: { + labels: ['已使用', '未使用'], + datasets: [{ + data: [0, 100], + backgroundColor: ['#4361ee', '#e9ecef'] + }] + }, + options: { + responsive: true, + maintainAspectRatio: false + } + }); + } + + // 性能图表 + const perfCtx = document.getElementById('performance-chart')?.getContext('2d'); + if (perfCtx) { + performanceChart = new Chart(perfCtx, { + type: 'line', + data: { + labels: Array(30).fill(''), + datasets: [ + { + label: 'TPS', + data: tpsHistory, + borderColor: '#4CAF50', + tension: 0.1, + yAxisID: 'y' + }, + { + label: 'MSPT', + data: msptHistory, + borderColor: '#FF5722', + tension: 0.1, + yAxisID: 'y1' + } + ] + }, + options: { + responsive: true, + interaction: { + mode: 'index', + intersect: false + }, + scales: { + y: { + type: 'linear', + display: true, + position: 'left', + title: { display: true, text: 'TPS' }, + min: 0, + max: 20 + }, + y1: { + type: 'linear', + display: true, + position: 'right', + title: { display: true, text: 'MSPT (ms)' }, + min: 0, + grid: { drawOnChartArea: false } + } + } + } + }); + } + } + function toggleTheme() { const currentTheme = document.documentElement.getAttribute('data-theme'); const newTheme = currentTheme === 'light' ? 'dark' : 'light'; @@ -56,65 +135,13 @@ document.addEventListener('DOMContentLoaded', function() { }, 1000); } - function initMemoryChart() { - const ctx = document.getElementById('memory-chart')?.getContext('2d'); - if (!ctx) return null; - - return new Chart(ctx, { - type: 'doughnut', - data: { - labels: ['已使用', '未使用'], - datasets: [{ - data: [0, 100], - backgroundColor: ['#4361ee', '#e9ecef'] - }] - }, - options: { - responsive: true, - maintainAspectRatio: false - } - }); - } - - function initPerformanceChart() { - const ctx = document.getElementById('performance-chart')?.getContext('2d'); - if (!ctx) return null; - - return new Chart(ctx, { - type: 'line', - data: { - labels: Array(10).fill('').map((_, i) => i + 1), - datasets: [{ - label: 'Tick时间 (ms)', - data: Array(10).fill(0), - borderColor: '#4361ee', - tension: 0.1, - fill: false - }] - }, - options: { - responsive: true, - maintainAspectRatio: false, - scales: { - y: { - beginAtZero: true, - title: { - display: true, - text: '毫秒' - } - } - } - } - }); - } - async function loadAllData() { try { await Promise.all([ fetchData('/api/stats', updateTable), fetchData('/api/online-players', updateOnlinePlayers), fetchData('/api/player-count', updatePlayerCounts), - fetchData('/api/server-status', (data) => updateServerStatus(data, memoryChart, performanceChart)) + fetchData('/api/server-status', updateServerStatus) ]); } catch (error) { console.error('加载数据出错:', error); @@ -184,7 +211,7 @@ document.addEventListener('DOMContentLoaded', function() { if (elements.nonWhitelistCount) elements.nonWhitelistCount.textContent = data.non_whitelisted || 0; } - function updateServerStatus(data, memoryChart, performanceChart) { + function updateServerStatus(data) { // 更新内存信息 if (data.memory) { const usedMB = Math.round(data.memory.used / (1024 * 1024)); @@ -203,14 +230,59 @@ document.addEventListener('DOMContentLoaded', function() { // 更新性能信息 if (data.server) { - if (performanceChart && data.server.recent_tick_samples_ms) { - performanceChart.data.datasets[0].data = data.server.recent_tick_samples_ms; + // 计算TPS (限制在0-20之间) + const tps = Math.min(20, 1000 / (data.server.average_tick_time_ms || 50)).toFixed(1); + const mspt = data.server.average_tick_time_ms.toFixed(1); + + // 更新显示 + if (elements.tpsValue) elements.tpsValue.textContent = tps; + if (elements.msptValue) elements.msptValue.textContent = mspt; + + // 更新图表数据 + if (performanceChart) { + // 更新历史数据 + tpsHistory.shift(); + tpsHistory.push(parseFloat(tps)); + msptHistory.shift(); + msptHistory.push(parseFloat(mspt)); + + // 只更新数据不重新创建图表 + performanceChart.data.datasets[0].data = tpsHistory; + performanceChart.data.datasets[1].data = msptHistory; performanceChart.update(); } if (elements.avgTick) elements.avgTick.textContent = data.server.average_tick_time_ms?.toFixed(2) || '0'; } + // 更新性能图表 + if (performanceChart && data.server) { + // 计算TPS和MSPT + const tps = Math.min(20, 1000 / (data.server.average_tick_time_ms || 50)).toFixed(1); + const mspt = data.server.average_tick_time_ms.toFixed(1); + + // 更新历史数据 + tpsHistory.shift(); + tpsHistory.push(parseFloat(tps)); + msptHistory.shift(); + msptHistory.push(parseFloat(mspt)); + + // 只更新数据(不重新创建图表) + performanceChart.data.datasets[0].data = tpsHistory; + performanceChart.data.datasets[1].data = msptHistory; + + // 平滑更新 + performanceChart.update('none'); + + // 更新数值显示 + if (elements.tpsValue) elements.tpsValue.textContent = tps; + if (elements.msptValue) elements.msptValue.textContent = mspt; + + // 根据数值设置颜色 + setMetricColor(elements.tpsValue, tps, 15, 10); + setMetricColor(elements.msptValue, mspt, 50, 100, true); + } + if (elements.uptime) elements.uptime.textContent = data.uptime_formatted || '0'; } @@ -237,4 +309,120 @@ document.addEventListener('DOMContentLoaded', function() { }, 5000); }, 10); } + + function initPerformanceChart() { + const perfCtx = document.getElementById('performance-chart')?.getContext('2d'); + if (!perfCtx) return null; + + return new Chart(perfCtx, { + type: 'line', + data: { + labels: Array(30).fill(''), + datasets: [ + { + label: 'TPS', + data: tpsHistory, + borderColor: '#4CAF50', + backgroundColor: 'rgba(76, 175, 80, 0.1)', + borderWidth: 2, + pointRadius: 3, + pointHoverRadius: 5, + tension: 0.3, + yAxisID: 'y' + }, + { + label: 'MSPT', + data: msptHistory, + borderColor: '#FF5722', + backgroundColor: 'rgba(255, 87, 34, 0.1)', + borderWidth: 2, + pointRadius: 3, + pointHoverRadius: 5, + tension: 0.3, + yAxisID: 'y1' + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { + duration: 500 + }, + plugins: { + legend: { + position: 'top', + labels: { + boxWidth: 12, + padding: 20, + font: { + size: 12 + } + } + }, + tooltip: { + mode: 'index', + intersect: false, + bodySpacing: 8 + } + }, + scales: { + x: { + grid: { + display: false + }, + ticks: { + maxRotation: 0, + autoSkip: true, + maxTicksLimit: 10 + } + }, + y: { + type: 'linear', + display: true, + position: 'left', + title: { + display: true, + text: 'TPS', + font: { + weight: 'bold' + } + }, + min: 0, + max: 20, + grid: { + color: 'rgba(0, 0, 0, 0.05)' + } + }, + y1: { + type: 'linear', + display: true, + position: 'right', + title: { + display: true, + text: 'MSPT (ms)', + font: { + weight: 'bold' + } + }, + min: 0, + grid: { + drawOnChartArea: false + } + } + } + } + }); + } + + function setMetricColor(element, value, warnThreshold, dangerThreshold, reverse = false) { + if (!element) return; + + const numValue = parseFloat(value); + element.style.color = + reverse ? + (numValue > dangerThreshold ? '#FF5722' : numValue > warnThreshold ? '#FFC107' : '#4CAF50') : + (numValue < dangerThreshold ? '#FF5722' : numValue < warnThreshold ? '#FFC107' : '#4CAF50'); + } + });