Compare commits

...

3 Commits

Author SHA1 Message Date
64623693e9 更新 README.md 2025-04-22 23:18:06 +08:00
BRanulf
387f1a83de 小更新 2025-04-19 22:29:36 +08:00
BRanulf
ef3f3dd1b8 更新网页和后端 2025-04-19 21:14:45 +08:00
7 changed files with 1016 additions and 118 deletions

View File

@ -1,4 +1,4 @@
# Player_OnlineTime # 我会永远视奸你们的👀
一个监视服务器玩家在线时间的mod带web服务器目前并不完善 一个监视服务器玩家在线时间的mod带web服务器目前并不完善

View File

@ -6,7 +6,7 @@ minecraft_version=1.21.4
yarn_mappings=1.21.4+build.8 yarn_mappings=1.21.4+build.8
loader_version=0.16.10 loader_version=0.16.10
# Mod Properties # Mod Properties
mod_version=1.14.514.118 mod_version=1.14.514.121
maven_group=org.example1 maven_group=org.example1
archives_base_name=playerOnlineTimeTrackerMod archives_base_name=playerOnlineTimeTrackerMod
# Dependencies # Dependencies

View File

@ -1,12 +1,26 @@
package com.example.playertime; package com.example.playertime;
import com.mojang.brigadier.arguments.IntegerArgumentType;
import net.fabricmc.api.ModInitializer; 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.event.lifecycle.v1.ServerLifecycleEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.fabricmc.loader.api.FabricLoader; 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.Logger;
import org.slf4j.LoggerFactory; 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 class PlayerTimeMod implements ModInitializer {
public static final Logger LOGGER = LoggerFactory.getLogger("PlayerTimeTracker"); public static final Logger LOGGER = LoggerFactory.getLogger("PlayerTimeTracker");
private static PlayerTimeTracker timeTracker; private static PlayerTimeTracker timeTracker;
@ -60,6 +74,7 @@ public class PlayerTimeMod implements ModInitializer {
LOGGER.error("[在线时间] Mod 初始化失败!", e); LOGGER.error("[在线时间] Mod 初始化失败!", e);
throw new RuntimeException("[在线时间] Mod 初始化失败", e); throw new RuntimeException("[在线时间] Mod 初始化失败", e);
} }
registerCommands();
} }
public static ModConfig getConfig() { public static ModConfig getConfig() {
@ -71,4 +86,107 @@ public class PlayerTimeMod implements ModInitializer {
public static PlayerTimeTracker getTimeTracker() { public static PlayerTimeTracker getTimeTracker() {
return timeTracker; 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<String, String> stats = tracker.getWhitelistedPlayerStats();
List<String> 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<String> 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);
}
} }

View File

@ -1,6 +1,7 @@
package com.example.playertime; package com.example.playertime;
import com.google.gson.*; import com.google.gson.*;
import com.mojang.authlib.GameProfile;
import com.sun.net.httpserver.HttpServer; import com.sun.net.httpserver.HttpServer;
import com.sun.net.httpserver.HttpHandler; import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpExchange;
@ -9,6 +10,7 @@ import net.minecraft.server.PlayerManager;
import net.minecraft.server.network.ServerPlayerEntity; import net.minecraft.server.network.ServerPlayerEntity;
import java.io.*; import java.io.*;
import java.lang.management.ManagementFactory;
import java.net.InetSocketAddress; import java.net.InetSocketAddress;
import java.net.URL; import java.net.URL;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@ -124,11 +126,9 @@ public class WebServer {
JsonObject response = new JsonObject(); JsonObject response = new JsonObject();
response.addProperty("onlineCount", playerManager.getCurrentPlayerCount()); response.addProperty("onlineCount", playerManager.getCurrentPlayerCount());
// 获取白名单在线玩家
JsonArray whitelistPlayers = new JsonArray(); JsonArray whitelistPlayers = new JsonArray();
Map<String, Long> playerTimeMap = new HashMap<>(); Map<String, Long> playerTimeMap = new HashMap<>();
// 先收集所有白名单玩家UUID
Set<UUID> whitelistUuids = new HashSet<>(); Set<UUID> whitelistUuids = new HashSet<>();
for (String name : playerManager.getWhitelist().getNames()) { for (String name : playerManager.getWhitelist().getNames()) {
server.getUserCache().findByName(name).ifPresent(profile -> { server.getUserCache().findByName(name).ifPresent(profile -> {
@ -136,7 +136,6 @@ public class WebServer {
}); });
} }
// 检查在线玩家
for (ServerPlayerEntity player : playerManager.getPlayerList()) { for (ServerPlayerEntity player : playerManager.getPlayerList()) {
UUID uuid = player.getUuid(); UUID uuid = player.getUuid();
if (whitelistUuids.contains(uuid)) { if (whitelistUuids.contains(uuid)) {
@ -152,7 +151,6 @@ public class WebServer {
} }
response.add("whitelistPlayers", whitelistPlayers); response.add("whitelistPlayers", whitelistPlayers);
// 获取时长前三玩家包括离线玩家
JsonArray topPlayers = new JsonArray(); JsonArray topPlayers = new JsonArray();
timeTracker.getPlayerData().entrySet().stream() timeTracker.getPlayerData().entrySet().stream()
.filter(entry -> whitelistUuids.contains(entry.getKey())) // 只筛选白名单玩家 .filter(entry -> whitelistUuids.contains(entry.getKey())) // 只筛选白名单玩家
@ -175,6 +173,239 @@ public class WebServer {
} }
}); });
// 在线玩家列表
server.createContext("/api/online-players", exchange -> {
exchange.getResponseHeaders().add("Access-Control-Allow-Origin", "*");
exchange.getResponseHeaders().add("Access-Control-Allow-Methods", "GET, OPTIONS");
exchange.getResponseHeaders().add("Access-Control-Allow-Headers", "Content-Type");
if ("OPTIONS".equals(exchange.getRequestMethod())) {
exchange.sendResponseHeaders(204, -1);
return;
}
if (!"GET".equals(exchange.getRequestMethod())) {
sendResponse(exchange, 405, "Method Not Allowed");
return;
}
try {
PlayerManager playerManager = minecraftServer.getPlayerManager();
JsonObject response = new JsonObject();
// 获取白名单玩家UUID集合
Set<UUID> whitelistUuids = new HashSet<>();
for (String name : playerManager.getWhitelist().getNames()) {
minecraftServer.getUserCache().findByName(name).ifPresent(profile -> {
whitelistUuids.add(profile.getId());
});
}
// 分类玩家
JsonArray whitelistedPlayers = new JsonArray();
JsonArray nonWhitelistedPlayers = new JsonArray();
for (ServerPlayerEntity player : playerManager.getPlayerList()) {
UUID uuid = player.getUuid();
JsonObject playerJson = new JsonObject();
playerJson.addProperty("name", player.getName().getString());
playerJson.addProperty("uuid", uuid.toString());
if (whitelistUuids.contains(uuid)) {
whitelistedPlayers.add(playerJson);
} else {
nonWhitelistedPlayers.add(playerJson);
}
}
response.add("whitelisted", whitelistedPlayers);
response.add("non_whitelisted", nonWhitelistedPlayers);
sendResponse(exchange, 200, GSON.toJson(response).getBytes(StandardCharsets.UTF_8), "application/json");
} catch (Exception e) {
PlayerTimeMod.LOGGER.error("[在线时间] 无法获取在线玩家列表", e);
sendResponse(exchange, 500, "Internal Server Error");
}
});
// 玩家计数
server.createContext("/api/player-count", exchange -> {
exchange.getResponseHeaders().add("Access-Control-Allow-Origin", "*");
exchange.getResponseHeaders().add("Access-Control-Allow-Methods", "GET, OPTIONS");
exchange.getResponseHeaders().add("Access-Control-Allow-Headers", "Content-Type");
if ("OPTIONS".equals(exchange.getRequestMethod())) {
exchange.sendResponseHeaders(204, -1);
return;
}
if (!"GET".equals(exchange.getRequestMethod())) {
sendResponse(exchange, 405, "Method Not Allowed");
return;
}
try {
PlayerManager playerManager = minecraftServer.getPlayerManager();
JsonObject response = new JsonObject();
// 获取白名单玩家UUID集合
Set<UUID> whitelistUuids = new HashSet<>();
for (String name : playerManager.getWhitelist().getNames()) {
minecraftServer.getUserCache().findByName(name).ifPresent(profile -> {
whitelistUuids.add(profile.getId());
});
}
// 分类计数
int whitelistedCount = 0;
int nonWhitelistedCount = 0;
for (ServerPlayerEntity player : playerManager.getPlayerList()) {
if (whitelistUuids.contains(player.getUuid())) {
whitelistedCount++;
} else {
nonWhitelistedCount++;
}
}
response.addProperty("total", playerManager.getCurrentPlayerCount());
response.addProperty("whitelisted", whitelistedCount);
response.addProperty("non_whitelisted", nonWhitelistedCount);
sendResponse(exchange, 200, GSON.toJson(response).getBytes(StandardCharsets.UTF_8), "application/json");
} catch (Exception e) {
PlayerTimeMod.LOGGER.error("[在线时间] 无法获取玩家计数", e);
sendResponse(exchange, 500, "Internal Server Error");
}
});
// 白名单玩家
server.createContext("/api/whitelist", exchange -> {
exchange.getResponseHeaders().add("Access-Control-Allow-Origin", "*");
exchange.getResponseHeaders().add("Access-Control-Allow-Methods", "GET, OPTIONS");
exchange.getResponseHeaders().add("Access-Control-Allow-Headers", "Content-Type");
if ("OPTIONS".equals(exchange.getRequestMethod())) {
exchange.sendResponseHeaders(204, -1);
return;
}
if (!"GET".equals(exchange.getRequestMethod())) {
sendResponse(exchange, 405, "Method Not Allowed");
return;
}
try {
PlayerManager playerManager = minecraftServer.getPlayerManager();
JsonArray whitelist = new JsonArray();
for (String name : playerManager.getWhitelist().getNames()) {
JsonObject player = new JsonObject();
player.addProperty("name", name);
// 尝试获取UUID
Optional<GameProfile> profile = minecraftServer.getUserCache().findByName(name);
if (profile.isPresent()) {
player.addProperty("uuid", profile.get().getId().toString());
// 检查是否在线
ServerPlayerEntity onlinePlayer = playerManager.getPlayer(profile.get().getId());
player.addProperty("online", onlinePlayer != null);
} else {
player.addProperty("online", false);
}
whitelist.add(player);
}
sendResponse(exchange, 200, GSON.toJson(whitelist).getBytes(StandardCharsets.UTF_8), "application/json");
} catch (Exception e) {
PlayerTimeMod.LOGGER.error("[在线时间] 无法获取白名单", e);
sendResponse(exchange, 500, "Internal Server Error");
}
});
// 服务器状态
server.createContext("/api/server-status", exchange -> {
exchange.getResponseHeaders().add("Access-Control-Allow-Origin", "*");
exchange.getResponseHeaders().add("Access-Control-Allow-Methods", "GET, OPTIONS");
exchange.getResponseHeaders().add("Access-Control-Allow-Headers", "Content-Type");
if ("OPTIONS".equals(exchange.getRequestMethod())) {
exchange.sendResponseHeaders(204, -1);
return;
}
if (!"GET".equals(exchange.getRequestMethod())) {
sendResponse(exchange, 405, "Method Not Allowed");
return;
}
try {
Runtime runtime = Runtime.getRuntime();
JsonObject status = new JsonObject();
long maxMemory = runtime.maxMemory();
long totalMemory = runtime.totalMemory();
long freeMemory = runtime.freeMemory();
long usedMemory = totalMemory - freeMemory;
JsonObject memory = new JsonObject();
memory.addProperty("max", maxMemory);
memory.addProperty("total", totalMemory);
memory.addProperty("used", usedMemory);
memory.addProperty("free", freeMemory);
memory.addProperty("usage_percentage", (double) usedMemory / maxMemory * 100);
status.add("memory", memory);
status.addProperty("available_processors", runtime.availableProcessors());
long uptime = ManagementFactory.getRuntimeMXBean().getUptime();
status.addProperty("uptime", uptime);
status.addProperty("uptime_formatted", formatUptime(uptime));
File diskPartition = new File(".");
long totalSpace = diskPartition.getTotalSpace();
long freeSpace = diskPartition.getFreeSpace();
long usableSpace = diskPartition.getUsableSpace();
JsonObject disk = new JsonObject();
disk.addProperty("total", totalSpace);
disk.addProperty("free", freeSpace);
disk.addProperty("usable", usableSpace);
disk.addProperty("usage_percentage", (double) (totalSpace - freeSpace) / totalSpace * 100);
status.add("disk", disk);
JsonObject serverInfo = new JsonObject();
serverInfo.addProperty("version", minecraftServer.getVersion());
serverInfo.addProperty("player_count", minecraftServer.getCurrentPlayerCount());
serverInfo.addProperty("max_players", minecraftServer.getMaxPlayerCount());
serverInfo.addProperty("average_tick_time_ms", minecraftServer.getAverageTickTime());
long[] tickTimes = minecraftServer.getTickTimes();
if (tickTimes != null && tickTimes.length > 0) {
double recentAvgTickTime = Arrays.stream(tickTimes).average().orElse(0) / 1000000.0;
serverInfo.addProperty("recent_avg_tick_time_ms", recentAvgTickTime);
JsonArray recentTicks = new JsonArray();
int sampleCount = Math.min(10, tickTimes.length);
for (int i = 0; i < sampleCount; i++) {
recentTicks.add(tickTimes[i] / 1000000.0);
}
serverInfo.add("recent_tick_samples_ms", recentTicks);
}
status.add("server", serverInfo);
sendResponse(exchange, 200, GSON.toJson(status).getBytes(StandardCharsets.UTF_8), "application/json");
} catch (Exception e) {
PlayerTimeMod.LOGGER.error("[在线时间] 无法获取服务器状态", e);
sendResponse(exchange, 500, "Internal Server Error");
}
});
@ -244,6 +475,18 @@ public class WebServer {
} }
} }
private String formatUptime(long millis) {
long seconds = millis / 1000;
long days = seconds / 86400;
seconds %= 86400;
long hours = seconds / 3600;
seconds %= 3600;
long minutes = seconds / 60;
seconds %= 60;
return String.format("%d天 %02d小时 %02d分钟 %02d秒", days, hours, minutes, seconds);
}
// 获取玩家总时长 // 获取玩家总时长
private long getPlayerTotalTime(String uuid, JsonObject timeData) { private long getPlayerTotalTime(String uuid, JsonObject timeData) {
if (!timeData.has(uuid)) { if (!timeData.has(uuid)) {

View File

@ -431,3 +431,194 @@ tbody tr:nth-child(10) { animation-delay: 0.5s; }
font-size: 0.8rem; font-size: 0.8rem;
} }
} }
.player-counts {
display: flex;
flex-direction: column;
gap: 8px;
}
.count-item {
display: flex;
justify-content: space-between;
}
.count-label {
font-weight: 500;
}
.count-value {
color: var(--primary-color-c);
}
.player-lists {
display: flex;
gap: 20px;
}
.player-list {
flex: 1;
}
.player-list h4 {
margin-bottom: 8px;
color: var(--primary-color-c);
}
.status-section {
margin: 2rem 0;
}
.status-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-top: 1rem;
}
@media (max-width: 768px) {
.status-grid {
grid-template-columns: 1fr;
}
.player-lists {
flex-direction: column;
}
}
.status-item {
background: var(--card-bg);
padding: 1rem;
border-radius: 8px;
box-shadow: var(--shadow);
}
.status-item h3 {
margin-top: 0;
color: var(--primary-color-c);
}
.memory-stats, .performance-stats {
margin-top: 1rem;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.memory-stats div, .performance-stats div {
font-size: 0.9rem;
}
canvas {
max-height: 300px;
width: 100% !important;
}
.error-message {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: #ff4444;
color: white;
padding: 10px 20px;
border-radius: 5px;
opacity: 0;
transition: opacity 0.3s;
z-index: 1000;
}
.error-message.show {
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;
}
}

View File

@ -3,10 +3,11 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>[在线时间] 玩家在线时间</title> <title>[在线时间] 玩家在线时间及服务器状态</title>
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
@ -21,22 +22,78 @@
<div class="warning">数据统计时间开始于此MOD安装时间不包含安装之前的所有数据</div> <div class="warning">数据统计时间开始于此MOD安装时间不包含安装之前的所有数据</div>
<br> <br>
<!-- 新增的在线状态卡片 --> <!-- 更新后的状态卡片 -->
<div class="status-cards"> <div class="status-cards">
<div class="status-card">
<h3>当前在线</h3>
<div class="online-count" id="online-count">0</div>
</div>
<div class="status-card"> <div class="status-card">
<h3>在线玩家</h3> <h3>在线玩家</h3>
<ul class="online-players" id="online-players"></ul> <div class="player-counts">
<div class="count-item">
<span class="count-label">总数:</span>
<span class="count-value" id="total-count">0</span>
</div>
<div class="count-item">
<span class="count-label">玩家:</span>
<span class="count-value" id="whitelist-count">0</span>
</div>
<div class="count-item">
<span class="count-label">假人:</span>
<span class="count-value" id="non-whitelist-count">0</span>
</div>
</div>
</div> </div>
<div class="status-card"> <div class="status-card">
<h3>时长前三</h3> <h3>在线玩家列表</h3>
<ul class="top-players" id="top-players"></ul> <div class="player-lists">
<div class="player-list">
<h4>玩家</h4>
<ul class="online-players" id="whitelist-players"></ul>
</div>
<div class="player-list">
<h4>假人</h4>
<ul class="online-players" id="non-whitelist-players"></ul>
</div>
</div>
</div> </div>
</div> </div>
<!-- 服务器状态可视化 -->
<div class="status-section">
<h2>服务器状态</h2>
<div class="status-grid">
<div class="status-item">
<h3>内存使用</h3>
<canvas id="memory-chart"></canvas>
<div class="memory-stats">
<div>已用: <span id="memory-used">0</span> MB</div>
<div>可用: <span id="memory-free">0</span> MB</div>
<div>使用率: <span id="memory-percent">0</span>%</div>
</div>
</div>
<div class="status-item">
<h3>实时性能</h3>
<div class="chart-container">
<div class="metric-display">
<div class="metric">
<span class="metric-label">TPS:</span>
<span class="metric-value" id="tps-value">0.0</span>
</div>
<div class="metric">
<span class="metric-label">MSPT:</span>
<span class="metric-value" id="mspt-value">0.0</span>
</div>
</div>
<div class="chart-wrapper">
<canvas id="performance-chart"></canvas>
</div>
</div>
</div>
</div>
</div>
<!-- 玩家时长统计表格 -->
<h2>玩家在线时长 (白名单)</h2>
<div class="controls"> <div class="controls">
<button id="refresh-btn" class="refresh-btn"> <button id="refresh-btn" class="refresh-btn">
<i class="fas fa-sync-alt"></i> <i class="fas fa-sync-alt"></i>
@ -45,6 +102,8 @@
<p class="info-note">仅跟踪和显示列入白名单的玩家</p> <p class="info-note">仅跟踪和显示列入白名单的玩家</p>
</div> </div>
<div class="stats-container"> <div class="stats-container">
<table id="stats-table"> <table id="stats-table">
<thead> <thead>
<tr> <tr>
@ -65,6 +124,5 @@
<p>Copyright © 2025 <a href="https://git.branulf.top/BRanulf" target="_blank">BRanulf</a></p> <p>Copyright © 2025 <a href="https://git.branulf.top/BRanulf" target="_blank">BRanulf</a></p>
</div> </div>
</footer> </footer>
</body> </body>
</html> </html>

View File

@ -1,116 +1,298 @@
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const refreshBtn = document.getElementById('refresh-btn'); // 1. 首先初始化所有变量
const themeToggle = document.getElementById('theme-toggle'); const tpsHistory = Array(30).fill(20);
const statsTable = document.getElementById('stats-table').getElementsByTagName('tbody')[0]; const msptHistory = Array(30).fill(50);
const onlineCountEl = document.getElementById('online-count'); let memoryChart = null;
const onlinePlayersEl = document.getElementById('online-players'); let performanceChart = null;
const topPlayersEl = document.getElementById('top-players');
// 初始化主题 // 2. 获取DOM元素带安全检查
const elements = {
refreshBtn: document.getElementById('refresh-btn'),
themeToggle: document.getElementById('theme-toggle'),
statsTable: document.getElementById('stats-table')?.getElementsByTagName('tbody')[0],
whitelistPlayers: document.getElementById('whitelist-players'),
nonWhitelistPlayers: document.getElementById('non-whitelist-players'),
totalCount: document.getElementById('total-count'),
whitelistCount: document.getElementById('whitelist-count'),
nonWhitelistCount: document.getElementById('non-whitelist-count'),
memoryUsed: document.getElementById('memory-used'),
memoryFree: document.getElementById('memory-free'),
memoryPercent: document.getElementById('memory-percent'),
avgTick: document.getElementById('avg-tick'),
uptime: document.getElementById('uptime'),
tpsValue: document.getElementById('tps-value'),
msptValue: document.getElementById('mspt-value')
};
// 3. 初始化主题
const savedTheme = localStorage.getItem('theme') || 'light'; const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', savedTheme); document.documentElement.setAttribute('data-theme', savedTheme);
// 主题切换功能 // 4. 初始化图表(必须在其他函数之前)
themeToggle.addEventListener('click', () => { initCharts();
// 5. 事件监听器
if (elements.themeToggle) {
elements.themeToggle.addEventListener('click', toggleTheme);
}
if (elements.refreshBtn) {
elements.refreshBtn.addEventListener('click', handleRefresh);
}
// 6. 初始加载数据
loadAllData();
// 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 currentTheme = document.documentElement.getAttribute('data-theme');
const newTheme = currentTheme === 'light' ? 'dark' : 'light'; const newTheme = currentTheme === 'light' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', newTheme); document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme); localStorage.setItem('theme', newTheme);
});
loadAllData();
refreshBtn.addEventListener('click', function() {
refreshBtn.classList.add('loading');
loadAllData();
setTimeout(() => {
refreshBtn.classList.remove('loading');
}, 1000);
});
function loadAllData() {
// 加载统计数据
fetch('/api/stats')
.then(response => response.json())
.then(data => {
updateTable(data);
})
.catch(error => {
console.error('获取统计信息时出错:', error);
showError('未能加载玩家统计信息。 检查控制台以获取详细信息');
});
// 加载在线状态数据
fetch('/api/widget-data')
.then(response => response.json())
.then(data => {
updateOnlineStatus(data);
})
.catch(error => {
console.error('获取在线状态时出错:', error);
showError('未能加载在线状态信息。 检查控制台以获取详细信息');
});
} }
function updateTable(statsData) { function handleRefresh() {
statsTable.innerHTML = ''; this.classList.add('loading');
loadAllData();
setTimeout(() => {
this.classList.remove('loading');
}, 1000);
}
Object.entries(statsData).forEach(([playerName, statString]) => { async function loadAllData() {
const row = statsTable.insertRow(); try {
await Promise.all([
fetchData('/api/stats', updateTable),
fetchData('/api/online-players', updateOnlinePlayers),
fetchData('/api/player-count', updatePlayerCounts),
fetchData('/api/server-status', updateServerStatus)
]);
} catch (error) {
console.error('加载数据出错:', error);
showError('加载数据失败,请检查控制台');
}
}
const nameCell = row.insertCell(0); async function fetchData(url, callback) {
nameCell.textContent = playerName; try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
callback(data);
} catch (error) {
console.error(`获取 ${url} 数据失败:`, error);
throw error;
}
}
function updateTable(data) {
if (!elements.statsTable) return;
elements.statsTable.innerHTML = '';
const sortedPlayers = Object.entries(data)
.map(([name, statString]) => {
const stats = {}; const stats = {};
statString.split(" | ").forEach(part => { statString.split(" | ").forEach(part => {
const [label, value] = part.split(": "); const [label, value] = part.split(": ");
stats[label.trim()] = value; stats[label.trim()] = value;
}); });
return { name, stats };
})
.sort((a, b) => parseTimeToSeconds(b.stats["总时长"]) - parseTimeToSeconds(a.stats["总时长"]));
const totalCell = row.insertCell(1); sortedPlayers.forEach(player => {
totalCell.textContent = stats["总时长"]; const row = elements.statsTable.insertRow();
row.insertCell(0).textContent = player.name;
const thirtyCell = row.insertCell(2); row.insertCell(1).textContent = player.stats["总时长"];
thirtyCell.textContent = stats["30天"]; row.insertCell(2).textContent = player.stats["30天"];
row.insertCell(3).textContent = player.stats["7天"];
const sevenCell = row.insertCell(3);
sevenCell.textContent = stats["7天"];
}); });
} }
function updateOnlineStatus(data) { function updateOnlinePlayers(data) {
// 更新在线人数 if (!elements.whitelistPlayers || !elements.nonWhitelistPlayers) return;
onlineCountEl.textContent = data.onlineCount;
// 更新在线玩家列表 const updateList = (element, players, emptyMessage) => {
onlinePlayersEl.innerHTML = ''; element.innerHTML = '';
if (data.whitelistPlayers && data.whitelistPlayers.length > 0) { if (players?.length > 0) {
data.whitelistPlayers.forEach(player => { players.forEach(player => {
const li = document.createElement('li'); const li = document.createElement('li');
li.innerHTML = ` li.innerHTML = `<span class="player-name">${player.name}</span>`;
<span class="player-name">${player.name}</span> element.appendChild(li);
<span class="player-time">${player.time}</span>
`;
onlinePlayersEl.appendChild(li);
}); });
} else { } else {
onlinePlayersEl.innerHTML = '<li>暂无在线玩家</li>'; element.innerHTML = `<li>${emptyMessage}</li>`;
}
};
updateList(elements.whitelistPlayers, data.whitelisted, '暂无玩家在线');
updateList(elements.nonWhitelistPlayers, data.non_whitelisted, '暂无假人在线');
} }
// 更新时长前三玩家 function updatePlayerCounts(data) {
topPlayersEl.innerHTML = ''; if (elements.totalCount) elements.totalCount.textContent = data.total || 0;
if (data.topPlayers && data.topPlayers.length > 0) { if (elements.whitelistCount) elements.whitelistCount.textContent = data.whitelisted || 0;
data.topPlayers.forEach(player => { if (elements.nonWhitelistCount) elements.nonWhitelistCount.textContent = data.non_whitelisted || 0;
const li = document.createElement('li');
li.innerHTML = `
<span class="player-name">${player.name}</span>
<span class="player-time">${player.time}</span>
`;
topPlayersEl.appendChild(li);
});
} else {
topPlayersEl.innerHTML = '<li>暂无数据</li>';
} }
function updateServerStatus(data) {
// 更新内存信息
if (data.memory) {
const usedMB = Math.round(data.memory.used / (1024 * 1024));
const freeMB = Math.round((data.memory.max - data.memory.used) / (1024 * 1024));
const percent = Math.round(data.memory.usage_percentage);
if (memoryChart) {
memoryChart.data.datasets[0].data = [usedMB, freeMB];
memoryChart.update();
}
if (elements.memoryUsed) elements.memoryUsed.textContent = usedMB;
if (elements.memoryFree) elements.memoryFree.textContent = freeMB;
if (elements.memoryPercent) elements.memoryPercent.textContent = percent;
}
// 更新性能信息
if (data.server) {
// 计算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';
}
function parseTimeToSeconds(timeStr) {
if (!timeStr) return 0;
return timeStr.split(' ').reduce((total, part) => {
if (part.includes('h')) return total + parseInt(part.replace('h', '')) * 3600;
if (part.includes('m')) return total + parseInt(part.replace('m', '')) * 60;
return total;
}, 0);
} }
function showError(message) { function showError(message) {
@ -121,20 +303,126 @@ document.addEventListener('DOMContentLoaded', function() {
setTimeout(() => { setTimeout(() => {
errorEl.classList.add('show'); errorEl.classList.add('show');
}, 10);
setTimeout(() => { setTimeout(() => {
errorEl.classList.remove('show'); errorEl.classList.remove('show');
setTimeout(() => { setTimeout(() => document.body.removeChild(errorEl), 300);
document.body.removeChild(errorEl);
}, 300);
}, 5000); }, 5000);
}, 10);
} }
function parseTime(timeStr) { function initPerformanceChart() {
const [hPart, mPart] = timeStr.split(' '); const perfCtx = document.getElementById('performance-chart')?.getContext('2d');
const hours = parseInt(hPart.replace('h', '')); if (!perfCtx) return null;
const minutes = parseInt(mPart.replace('m', ''));
return hours * 60 + minutes; 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');
}
});