Compare commits

...

10 Commits

Author SHA1 Message Date
BRanulf
e5eca44059 qwq 2025-06-23 14:04:22 +08:00
BRanulf
c3e4a1b483 qwq 2025-06-23 14:01:09 +08:00
BRanulf
75bacc1ec7 利用已有api添加新元素 2025-06-09 18:27:05 +08:00
BRanulf
0d9e2db5ee 修bug修bug 2025-06-09 17:34:05 +08:00
BRanulf
eef2bbebad 修bug修bug 2025-06-07 14:48:03 +08:00
BRanulf
a5b4efc36e aaaaaa 2025-06-06 12:52:15 +08:00
BRanulf
62ef19006a markdown 2025-06-05 18:18:00 +08:00
BRanulf
a69adaf749 Merge remote-tracking branch 'origin/master' 2025-06-05 18:17:03 +08:00
BRanulf
7e03e0edfa markdown 2025-06-05 18:16:43 +08:00
Branulf
d3523f2442 更新 README_EN.md 2025-06-05 14:54:28 +08:00
23 changed files with 2393 additions and 1601 deletions

View File

@ -1,6 +1,7 @@
# 一个服务器在线玩家时间以及服务器状态的简单的mod # 一个服务器在线玩家时间以及服务器状态的简单的mod
[English](https://git.branulf.top/Branulf/ServerPlayerOnlineTracker/src/branch/master/README_EN.md) [English](https://git.branulf.top/Branulf/ServerPlayerOnlineTracker/src/branch/master/README_EN.md)
注意本mod并不完善可能会存在些许的bug
### 包含功能: ### 包含功能:
* 显示/记录服务器在线玩家时间 * 显示/记录服务器在线玩家时间
* 显示服务器状态 * 显示服务器状态
@ -12,6 +13,7 @@
2. 将最新版本的mod放入mods文件夹 2. 将最新版本的mod放入mods文件夹
3. 首次启动服务器自动生成配置文件 3. 首次启动服务器自动生成配置文件
4. 修改Web服务器端口(默认60048),语言(目前仅支持zh_cn和en_us),自动保存时间(默认300秒) 4. 修改Web服务器端口(默认60048),语言(目前仅支持zh_cn和en_us),自动保存时间(默认300秒)
5. 如需在外部访问请确保端口已开放
### API获取数据 ### API获取数据
*(也许可以用于机器人?)* *(也许可以用于机器人?)*

View File

@ -1,22 +1,24 @@
# A simple mod for online player time and server status # A simple mod for tracking online player time and server status
[中文简体](https://git.branulf.top/Branulf/ServerPlayerOnlineTracker/src/branch/master/README.md) [中文简体](https://git.branulf.top/Branulf/ServerPlayerOnlineTracker/src/branch/master/README.md)
### Includes functions: Note: This mod is not perfect and may have some bugs.
* Display /Record server online player time ### Features include:
* Display server status * Display/record server online player time
* Show the server online player list * Show server status
* ~~Remove Herobrine in the game~~ * Display a list of online players on the server
* ~~Remove Herobrine from the game~~
### How to use: ### How to use:
1. Install Fabric Loader and Fabric API 1. Install Fabric Loader and Fabric API.
2. Put the latest version of mods into the mods folder 2. Place the latest version of the mod into the mods folder.
3. Automatically generate configuration files when starting the server for the first time 3. The configuration file will be generated automatically when starting the server for the first time.
4. Modify the web server port (default 60048), language (currently only supports zh_cn and en_us), automatic save time (default 300 seconds) 4. Modify the web server port (default 60048), language (currently only supports zh_cn and en_us), and automatic save interval (default 300 seconds).
5. If external access is needed, ensure that the port is open.
### API to get data: ### API data retrieval:
*(Maybe it can be used for robots?)* *(Maybe can be used for bots?)*
* Get the online player list of servers: [Server IP+configured port] +`/api/online-players` * Get a list of online players on the server: [Server IP + configured port] + `/api/online-players`
* Get server status: [Server IP+configured port] +`/api/server-status` * Get server status: [Server IP + configured port] + `/api/server-status`
* Get the server online player time: [Server IP+configured port] +`/api/stats` * Get online player time on the server: [Server IP + configured port] + `/api/stats`
* Get the number of online players on the server: [Server IP+configured port] +`/api/player-count` * Get number of online players on the server: [Server IP + configured port] + `/api/player-count`
* ~~Raw data file: [Server IP+configured port] +`/api/playerdata`~~ * ~~Raw data file: [Server IP + configured port] + `/api/playerdata`~~

View File

@ -92,3 +92,5 @@ publishing {
// retrieving dependencies. // retrieving dependencies.
} }
} }

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.130 mod_version=1.14.514.141
maven_group=org.example1 maven_group=org.example1
archives_base_name=ServerPlayerOnlineTracker archives_base_name=ServerPlayerOnlineTracker
# Dependencies # Dependencies

View File

@ -12,39 +12,45 @@ import java.text.MessageFormat;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
// 这个是gemini写的 // 这个是gemini写的 (保留原注释但代码已根据需求调整)
public class LocalizationManager { public class LocalizationManager {
private final Map<String, String> translations = new HashMap<>(); private final Map<String, String> translations = new HashMap<>();
private final String defaultLanguage = "zh_cn"; private final String defaultLanguage = "zh_cn"; // 默认语言代码
public LocalizationManager(String languageCode) { public LocalizationManager(String languageCode) {
loadLanguage(languageCode); // 尝试加载用户配置的语言
if (!languageCode.equals(defaultLanguage)) { loadLanguage(languageCode, false);
// 如果配置的语言不是默认语言加载默认语言作为备用
if (!languageCode.equalsIgnoreCase(defaultLanguage)) {
loadLanguage(defaultLanguage, true); loadLanguage(defaultLanguage, true);
} }
// 如果配置的语言和默认语言都没加载成功或者配置的语言文件不完整确保默认语言至少被加载
if (translations.isEmpty() && !languageCode.equalsIgnoreCase(defaultLanguage)) {
PlayerTimeMod.LOGGER.warn("[PlayerTime] 未能加载配置的语言文件({})或默认语言文件({})Mod将使用原始键。", languageCode, defaultLanguage);
} else if (translations.isEmpty() && languageCode.equalsIgnoreCase(defaultLanguage)) {
PlayerTimeMod.LOGGER.warn("[PlayerTime] 未能加载默认语言文件({})Mod将使用原始键。", defaultLanguage);
}
} }
private void loadLanguage(String languageCode) {
loadLanguage(languageCode, false);
}
private void loadLanguage(String languageCode, boolean isFallback) { private void loadLanguage(String languageCode, boolean isFallback) {
String resourcePath = String.format("assets/playertime/lang/%s.json", languageCode); String resourcePath = String.format("assets/playertime/lang/%s.json", languageCode.toLowerCase()); // 确保小写
try (InputStream is = FabricLoader.getInstance().getModContainer("playertime") try {
.flatMap(container -> container.findPath(resourcePath)) // 使用 FabricLoader 获取 Mod 资源
.map(path -> { InputStream is = FabricLoader.getInstance().getModContainer("playertime")
try { .flatMap(container -> container.findPath(resourcePath))
return path.toUri().toURL().openStream(); .map(path -> {
} catch (Exception e) { try {
return null; return path.toUri().toURL().openStream();
} } catch (Exception e) {
}) // 查找路径或打开流失败
.orElse(null)) { return null;
}
})
.orElse(null); // 如果找不到路径或打开失败返回 null
if (is == null) { if (is == null) {
if (!isFallback) { if (!isFallback) {
PlayerTimeMod.LOGGER.warn("[PlayerTime] Language file not found for code: {}", languageCode); PlayerTimeMod.LOGGER.warn("[PlayerTime] 未找到语言文件: {}", resourcePath);
} }
return; return;
} }
@ -52,33 +58,51 @@ public class LocalizationManager {
try (InputStreamReader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) { try (InputStreamReader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) {
JsonObject json = JsonParser.parseReader(reader).getAsJsonObject(); JsonObject json = JsonParser.parseReader(reader).getAsJsonObject();
json.entrySet().forEach(entry -> { json.entrySet().forEach(entry -> {
// Only add if not already present (fallback won't overwrite primary) // 如果是加载备用语言只有当主语言没有该键时才添加
if (!translations.containsKey(entry.getKey())) { // 如果是加载主语言则覆盖备用语言中的同名键
translations.put(entry.getKey(), entry.getValue().getAsString()); if (!translations.containsKey(entry.getKey()) || !isFallback) {
} else if (!isFallback) {
// If loading primary, overwrite fallback
translations.put(entry.getKey(), entry.getValue().getAsString()); translations.put(entry.getKey(), entry.getValue().getAsString());
} }
}); });
PlayerTimeMod.LOGGER.info("[PlayerTime] Loaded {} language strings for code: {}", translations.size(), languageCode); PlayerTimeMod.LOGGER.info("[PlayerTime] 已加载 {} 语言字符串 ({}): {}", translations.size(), isFallback ? "备用" : "主要", languageCode);
} }
} catch (Exception e) { } catch (Exception e) {
PlayerTimeMod.LOGGER.error("[PlayerTime] Failed to load language file for code: {}", languageCode, e); PlayerTimeMod.LOGGER.error("[PlayerTime] 加载语言文件失败 ({}): {}", languageCode, resourcePath, e);
} }
} }
/**
* 根据键获取本地化字符串如果找不到键返回键本身
* @param key 本地化键
* @return 本地化字符串或原始键
*/
public String getString(String key) { public String getString(String key) {
return translations.getOrDefault(key, key); return translations.getOrDefault(key, key);
} }
/**
* 根据键获取本地化字符串并使用参数格式化如果找不到键返回原始键
* @param key 本地化键
* @param args 格式化参数
* @return 格式化后的本地化字符串或原始键
*/
public String getString(String key, Object... args) { public String getString(String key, Object... args) {
String pattern = getString(key); String pattern = getString(key);
try { try {
// MessageFormat 可以处理 {0}, {1} 等占位符
return MessageFormat.format(pattern, args); return MessageFormat.format(pattern, args);
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
PlayerTimeMod.LOGGER.error("[PlayerTime] Failed to format localization string for key: {}", key, e); PlayerTimeMod.LOGGER.error("[PlayerTime] 格式化本地化字符串失败 (键: {})", key, e);
return pattern; return pattern; // 格式化失败时返回原始模式
} }
} }
/**
* 获取所有本地化字符串的映射主要用于 Web API
* @return 包含所有本地化键值对的 Map
*/
public Map<String, String> getAllStrings() {
return new HashMap<>(translations); // 返回副本防止外部修改
}
} }

View File

@ -7,19 +7,23 @@ import java.nio.file.*;
public class ModConfig { public class ModConfig {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private final Path configPath; private final Path configPath;
// 配置项
private int webPort = 60048; private int webPort = 60048;
private String language = "zh_cn"; private String language = "zh_cn";
private long autoSaveSeconds = 300; private boolean whitelistOnly = true; // 默认只记录白名单玩家
private int saveIntervalMinutes = 5; // 默认每5分钟保存一次
public ModConfig(Path configDir) { public ModConfig(Path configDir) {
this.configPath = configDir.resolve("playertime-config.json"); this.configPath = configDir.resolve("playertime-config.json");
loadConfig(); loadConfig();
} }
// 加载配置
private void loadConfig() { private void loadConfig() {
if (!Files.exists(configPath)) { if (!Files.exists(configPath)) {
PlayerTimeMod.LOGGER.info("[在线时间] 配置文件未找到,正在创建默认配置"); PlayerTimeMod.LOGGER.info("[在线时间] 配置文件未找到,正在创建默认配置: {}", configPath);
saveConfig(); saveConfig(); // 创建默认配置并保存
return; return;
} }
@ -27,35 +31,68 @@ public class ModConfig {
JsonElement jsonElement = JsonParser.parseReader(reader); JsonElement jsonElement = JsonParser.parseReader(reader);
if (jsonElement == null || !jsonElement.isJsonObject()) { if (jsonElement == null || !jsonElement.isJsonObject()) {
PlayerTimeMod.LOGGER.warn("[在线时间] 配置文件为空或格式错误,正在使用默认配置并覆盖"); PlayerTimeMod.LOGGER.warn("[在线时间] 配置文件为空或格式错误,正在使用默认配置并覆盖: {}", configPath);
saveConfig(); saveConfig(); // 使用默认配置并覆盖
return; return;
} }
JsonObject json = jsonElement.getAsJsonObject(); JsonObject json = jsonElement.getAsJsonObject();
if (json.has("webPort")) { // 读取 webPort
if (json.has("webPort") && json.get("webPort").isJsonPrimitive() && json.get("webPort").getAsJsonPrimitive().isNumber()) {
webPort = json.get("webPort").getAsInt(); webPort = json.get("webPort").getAsInt();
if (webPort < 1 || webPort > 65535) {
PlayerTimeMod.LOGGER.warn("[在线时间] 配置文件中的 webPort ({}) 无效,使用默认值 {}", webPort, 60048);
webPort = 60048;
}
} else { } else {
PlayerTimeMod.LOGGER.info("[在线时间] 配置文件缺少“webPort”字段添加默认值'%s'并保存", webPort); PlayerTimeMod.LOGGER.info("[在线时间] 配置文件缺少或 webPort 格式错误,使用默认值 {}", webPort);
saveConfig(); // 不立即保存等待所有字段读取完毕再决定是否保存
}
if (json.has("language")) {
language = json.get("language").getAsString();
} else {
PlayerTimeMod.LOGGER.info("[在线时间] 配置文件缺少“language”字段添加默认值'%s'并保存", language);
saveConfig();
}
if (json.has("autoSaveSeconds")) {
autoSaveSeconds = json.get("autoSaveSeconds").getAsLong();
} else {
PlayerTimeMod.LOGGER.info("[在线时间] 配置文件缺少“autoSaveSeconds”字段添加默认值'%s'并保存", autoSaveSeconds);
saveConfig();
} }
// 读取 language
if (json.has("language") && json.get("language").isJsonPrimitive() && json.get("language").getAsJsonPrimitive().isString()) {
language = json.get("language").getAsString();
} else {
PlayerTimeMod.LOGGER.info("[在线时间] 配置文件缺少或 language 格式错误,使用默认值 {}", language);
}
// 读取 whitelistOnly
if (json.has("whitelistOnly") && json.get("whitelistOnly").isJsonPrimitive() && json.get("whitelistOnly").getAsJsonPrimitive().isBoolean()) {
whitelistOnly = json.get("whitelistOnly").getAsBoolean();
} else {
PlayerTimeMod.LOGGER.info("[在线时间] 配置文件缺少或 whitelistOnly 格式错误,使用默认值 {}", whitelistOnly);
}
// 读取 saveIntervalMinutes
if (json.has("saveIntervalMinutes") && json.get("saveIntervalMinutes").isJsonPrimitive() && json.get("saveIntervalMinutes").getAsJsonPrimitive().isNumber()) {
saveIntervalMinutes = json.get("saveIntervalMinutes").getAsInt();
if (saveIntervalMinutes < 0) {
PlayerTimeMod.LOGGER.warn("[在线时间] 配置文件中的 saveIntervalMinutes ({}) 无效,使用默认值 {}", saveIntervalMinutes, 5);
saveIntervalMinutes = 5;
}
} else {
PlayerTimeMod.LOGGER.info("[在线时间] 配置文件缺少或 saveIntervalMinutes 格式错误,使用默认值 {}", saveIntervalMinutes);
}
// 检查是否有新字段需要添加到配置文件
if (!json.has("webPort") || !json.has("language") || !json.has("whitelistOnly") || !json.has("saveIntervalMinutes")) {
PlayerTimeMod.LOGGER.info("[在线时间] 配置文件缺少部分字段,正在添加并保存。");
saveConfig(); // 保存以添加新字段
} else {
PlayerTimeMod.LOGGER.info("[在线时间] 配置文件加载成功: {}", configPath);
}
} catch (IOException e) {
PlayerTimeMod.LOGGER.error("[在线时间] 无法读取配置文件 {},使用默认配置", configPath, e);
saveConfig(); // 发生IO错误尝试保存默认配置
} catch (JsonParseException e) {
PlayerTimeMod.LOGGER.error("[在线时间] 配置文件 {} 格式错误,使用默认配置", configPath, e);
saveConfig(); // 发生JSON解析错误尝试保存默认配置
} catch (Exception e) { } catch (Exception e) {
PlayerTimeMod.LOGGER.error("[在线时间] 加载配置文件失败,使用默认配置", e); PlayerTimeMod.LOGGER.error("[在线时间] 加载配置文件 {} 时发生未知错误,使用默认配置", configPath, e);
saveConfig(); saveConfig(); // 发生未知错误尝试保存默认配置
} }
} }
@ -64,16 +101,17 @@ public class ModConfig {
JsonObject json = new JsonObject(); JsonObject json = new JsonObject();
json.addProperty("webPort", webPort); json.addProperty("webPort", webPort);
json.addProperty("language", language); json.addProperty("language", language);
json.addProperty("autoSaveSeconds", autoSaveSeconds); json.addProperty("whitelistOnly", whitelistOnly);
json.addProperty("saveIntervalMinutes", saveIntervalMinutes);
try { try {
Files.createDirectories(configPath.getParent()); Files.createDirectories(configPath.getParent());
try (Writer writer = Files.newBufferedWriter(configPath)) { try (Writer writer = Files.newBufferedWriter(configPath)) {
GSON.toJson(json, writer); GSON.toJson(json, writer);
} }
PlayerTimeMod.LOGGER.info("[在线时间] 配置已成功保存"); PlayerTimeMod.LOGGER.info("[在线时间] 配置已成功保存到 {}", configPath);
} catch (Exception e) { } catch (Exception e) {
PlayerTimeMod.LOGGER.error("[在线时间] 保存配置失败", e); PlayerTimeMod.LOGGER.error("[在线时间] 保存配置失败到 {}", configPath, e);
} }
} }
@ -87,8 +125,13 @@ public class ModConfig {
return language; return language;
} }
// 获取保存间隔 // 获取是否只记录白名单
public long getSeconds() { public boolean isWhitelistOnly() {
return autoSaveSeconds; return whitelistOnly;
}
// 获取保存间隔分钟
public int getSaveIntervalMinutes() {
return saveIntervalMinutes;
} }
} }

View File

@ -6,22 +6,30 @@ 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.fabricmc.loader.api.ModContainer;
import net.fabricmc.loader.api.Version;
import net.minecraft.server.command.CommandManager; import net.minecraft.server.command.CommandManager;
import net.minecraft.server.command.ServerCommandSource; import net.minecraft.server.command.ServerCommandSource;
import net.minecraft.server.network.ServerPlayerEntity; import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.text.ClickEvent; import net.minecraft.text.ClickEvent;
import net.minecraft.text.MutableText; import net.minecraft.text.MutableText;
import net.minecraft.text.Text; import net.minecraft.text.Text;
import net.minecraft.util.Formatting;
import net.minecraft.util.Util; import net.minecraft.util.Util;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.DocumentBuilder;
import org.w3c.dom.Document;
import org.w3c.dom.NodeList;
import org.w3c.dom.Element;
import java.net.URL;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.CompletableFuture; import java.util.Optional;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -30,105 +38,106 @@ public class PlayerTimeMod implements ModInitializer {
private static PlayerTimeTracker timeTracker; private static PlayerTimeTracker timeTracker;
private static WebServer webServer; private static WebServer webServer;
private static ModConfig config; private static ModConfig config;
public static LocalizationManager localizationManager; // 新增本地化管理器 public static LocalizationManager localizationManager;
// TODO 定时保存配置文件没整暂时硬编码 private static final String RSS_FEED_URL = "https://git.branulf.top/Branulf/ServerPlayerOnlineTracker/releases.rss";
private ScheduledExecutorService scheduler; private static final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2); // One for update check, one for saving
private ScheduledFuture<?> saveTask;
private static long AUTO_SAVE_INTERVAL_SECONDS;
@Override @Override
public void onInitialize() { public void onInitialize() {
config = new ModConfig(FabricLoader.getInstance().getConfigDir());
localizationManager = new LocalizationManager(config.getLanguage());
scheduler = Executors.newSingleThreadScheduledExecutor();
try { try {
LOGGER.info("[在线时间] 初始化 玩家在线时长视奸Mod"); LOGGER.info("[在线时间] 初始化 玩家在线时长视奸Mod...");
AUTO_SAVE_INTERVAL_SECONDS = config.getSeconds(); // 加载配置
config = new ModConfig(FabricLoader.getInstance().getConfigDir());
// 初始化本地化管理器
localizationManager = new LocalizationManager(config.getLanguage());
// 检查更新
checkForUpdates();
// 服务器启动中事件
ServerLifecycleEvents.SERVER_STARTING.register(server -> { ServerLifecycleEvents.SERVER_STARTING.register(server -> {
timeTracker = new PlayerTimeTracker(server); LOGGER.info("[在线时间] 服务器启动中...");
timeTracker = new PlayerTimeTracker(server, config);
try { try {
webServer = new WebServer(timeTracker, config.getWebPort(), server); // 传入 MinecraftServer webServer = new WebServer(timeTracker, config.getWebPort(), server);
webServer.start(); webServer.start();
LOGGER.info("[在线时间] Web服务器在端口 " + config.getWebPort() + "启动"); LOGGER.info("[在线时间] Web服务器在端口 {} 启动", config.getWebPort());
} catch (Exception e) { } catch (Exception e) {
LOGGER.error("[在线时间] 无法启动Web服务器", e); LOGGER.error("[在线时间] 无法启动Web服务器", e);
} }
LOGGER.info("[在线时间] 每{}秒({}分钟)安排自动保存任务。",
AUTO_SAVE_INTERVAL_SECONDS, AUTO_SAVE_INTERVAL_SECONDS / 60);
saveTask = scheduler.scheduleAtFixedRate(
() -> {
if (timeTracker != null) {
LOGGER.info("[在线时间] 自动保存玩家数据中...");
timeTracker.saveAll();
LOGGER.info("[在线时间] 自动保存完成。");
}
},
AUTO_SAVE_INTERVAL_SECONDS,
AUTO_SAVE_INTERVAL_SECONDS,
TimeUnit.SECONDS
);
}); });
// 服务器已启动事件
ServerLifecycleEvents.SERVER_STARTED.register(server -> {
LOGGER.info("[在线时间] 服务器已启动.");
if (timeTracker != null) {
timeTracker.loadData();
// 启动定时保存任务
int saveInterval = config.getSaveIntervalMinutes();
if (saveInterval > 0) {
scheduler.scheduleAtFixedRate(timeTracker::saveAll, saveInterval, saveInterval, TimeUnit.MINUTES);
LOGGER.info("[在线时间] 已安排每 {} 分钟定时保存玩家数据", saveInterval);
} else {
LOGGER.warn("[在线时间] 配置的保存间隔为 {} 分钟,定时保存已禁用。", saveInterval);
}
} else {
LOGGER.error("[在线时间] PlayerTimeTracker 未在 SERVER_STARTING 阶段成功初始化!");
}
});
// 玩家加入事件
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> { ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> {
if (timeTracker != null) { if (timeTracker != null) {
timeTracker.onPlayerJoin(handler.player); // 根据配置决定是否跟踪该玩家
if (!config.isWhitelistOnly() || server.getPlayerManager().isWhitelisted(handler.player.getGameProfile())) {
timeTracker.onPlayerJoin(handler.player);
} else {
LOGGER.debug("[在线时间] 玩家 {} ({}) 不在白名单,且配置为仅记录白名单玩家,跳过跟踪。", handler.player.getName().getString(), handler.player.getUuid());
}
} }
}); });
// 玩家离开事件
ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> { ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> {
if (timeTracker != null) { if (timeTracker != null) {
timeTracker.onPlayerLeave(handler.player); if (timeTracker.isPlayerTracked(handler.player.getUuid())) {
timeTracker.onPlayerLeave(handler.player);
} else {
LOGGER.debug("[在线时间] 玩家 {} ({}) 未被跟踪,跳过离开事件处理。", handler.player.getName().getString(), handler.player.getUuid());
}
} }
}); });
// 服务器停止事件
ServerLifecycleEvents.SERVER_STOPPING.register(server -> { ServerLifecycleEvents.SERVER_STOPPING.register(server -> {
LOGGER.info("[在线时间] 服务器停止 - 正在保存数据"); LOGGER.info("[在线时间] 服务器停止中 - 正在保存数据...");
if (saveTask != null) {
saveTask.cancel(false);
LOGGER.info("[在线时间] 自动保存任务已取消。");
}
if (scheduler != null && !scheduler.isShutdown()) {
scheduler.shutdown();
try {
if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) {
LOGGER.warn("[在线时间] 调度程序未在5秒内终止。");
}
} catch (InterruptedException e) {
LOGGER.error("[在线时间] 调度程序终止被中断", e);
Thread.currentThread().interrupt();
}
LOGGER.info("[在线时间] 调度程序关闭");
}
if (webServer != null) {
webServer.stop();
}
if (timeTracker != null) { if (timeTracker != null) {
for (ServerPlayerEntity player : server.getPlayerManager().getPlayerList()) { for (ServerPlayerEntity player : server.getPlayerManager().getPlayerList()) {
timeTracker.onPlayerLeave(player); if (timeTracker.isPlayerTracked(player.getUuid())) {
timeTracker.onPlayerLeave(player);
}
} }
timeTracker.saveAll(); timeTracker.saveAll();
} }
if (webServer != null) {
webServer.stop();
LOGGER.info("[在线时间] Web服务器已停止.");
}
scheduler.shutdownNow();
LOGGER.info("[在线时间] 定时任务已关闭.");
LOGGER.info("[在线时间] 数据保存完成Mod 已停止.");
}); });
} catch (Exception e) { } catch (Exception e) {
LOGGER.error("[在线时间] Mod出屎化失败", e); LOGGER.error("[在线时间] Mod初始化失败", e);
if (scheduler != null && !scheduler.isShutdown()) {
scheduler.shutdownNow();
}
throw new RuntimeException("[在线时间] Mod初始化失败 ", e); throw new RuntimeException("[在线时间] Mod初始化失败 ", e);
} }
registerCommands(); registerCommands();
} }
@ -144,12 +153,12 @@ public class PlayerTimeMod implements ModInitializer {
return localizationManager; return localizationManager;
} }
// 注册命令
public static void registerCommands() { private void registerCommands() {
CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> { CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> {
dispatcher.register(CommandManager.literal("onlineTime") dispatcher.register(CommandManager.literal("onlineTime")
.requires(source -> source.hasPermissionLevel(0)) .requires(source -> source.hasPermissionLevel(0)) // 任何玩家都可以使用
.executes(context -> showOnlineTime(context.getSource(), 1)) // 默认第一页 .executes(context -> showOnlineTime(context.getSource(), 1)) // 默认显示第一页
.then(CommandManager.argument("page", IntegerArgumentType.integer(1)) .then(CommandManager.argument("page", IntegerArgumentType.integer(1))
.executes(context -> showOnlineTime( .executes(context -> showOnlineTime(
context.getSource(), context.getSource(),
@ -157,95 +166,182 @@ public class PlayerTimeMod implements ModInitializer {
)) ))
) )
); );
LOGGER.info("[在线时间] 已注册命令 /onlineTime");
}); });
} }
private static int showOnlineTime(ServerCommandSource source, int requestedPage) { // 显示在线时间
private int showOnlineTime(ServerCommandSource source, int requestedPage) {
ServerPlayerEntity player = source.getPlayer(); ServerPlayerEntity player = source.getPlayer();
if (player == null) return 0; if (player == null) {
source.sendMessage(Text.literal(localizationManager.getString("playertime.command.error.player_only")).formatted(Formatting.RED));
return 0;
}
CompletableFuture.runAsync(() -> { Util.getMainWorkerExecutor().execute(() -> {
PlayerTimeTracker tracker = getTimeTracker(); PlayerTimeTracker tracker = getTimeTracker();
if (tracker != null) { if (tracker == null) {
Map<String, String> stats = tracker.getWhitelistedPlayerStats(); player.sendMessage(Text.literal(localizationManager.getString("playertime.command.error.not_initialized")).formatted(Formatting.RED), false);
return;
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());
List<PlayerTimeTracker.PlayerTimeStatsWithNames> statsList = tracker.getSortedPlayerStats();
sendPaginatedMessage(player, statsList, requestedPage);
});
return 1; return 1;
} }
private static int comparePlayTime(String a, String b) { // 发送分页消息
String timeA = a.substring(a.indexOf(':') + 1).trim().split(" \\| ")[0]; private void sendPaginatedMessage(ServerPlayerEntity player, List<PlayerTimeTracker.PlayerTimeStatsWithNames> statsList, int requestedPage) {
String timeB = b.substring(b.indexOf(':') + 1).trim().split(" \\| ")[0];
return Long.compare(parseTimeToSeconds(timeB), parseTimeToSeconds(timeA));
}
private static long parseTimeToSeconds(String timeStr) {
long seconds = 0;
String[] parts = timeStr.split(" ");
for (String part : parts) {
if (part.endsWith("h")) {
try {
seconds += Integer.parseInt(part.replace("h", "")) * 3600;
} catch (NumberFormatException ignored) {}
} else if (part.endsWith("m")) {
try {
seconds += Integer.parseInt(part.replace("m", "")) * 60;
} catch (NumberFormatException ignored) {}
}
}
return seconds;
}
private static String formatPlayerTime(String name, String timeStr) {
return String.format("§e%s§r: %s", name, timeStr);
}
private static void sendPaginatedMessage(ServerPlayerEntity player, List<String> lines, int page) {
int pageSize = 10; int pageSize = 10;
int totalPages = (lines.size() + pageSize - 1) / pageSize; int totalEntries = statsList.size();
page = Math.max(1, Math.min(page, totalPages)); int totalPages = (totalEntries + pageSize - 1) / pageSize;
int currentPage = Math.max(1, Math.min(requestedPage, Math.max(1, totalPages)));
int from = (page - 1) * pageSize; int fromIndex = (currentPage - 1) * pageSize;
int to = Math.min(from + pageSize, lines.size()); int toIndex = Math.min(fromIndex + pageSize, totalEntries);
player.sendMessage(Text.literal(localizationManager.getString("playertime.command.title", page, totalPages)), false); player.sendMessage(Text.literal(localizationManager.getString("playertime.command.title", currentPage, Math.max(1, totalPages))).formatted(Formatting.GOLD), false);
for (int i = from; i < to; i++) { if (statsList.isEmpty()) {
player.sendMessage(Text.literal(lines.get(i)), false); player.sendMessage(Text.literal(localizationManager.getString("playertime.command.empty_stats")).formatted(Formatting.GRAY), false);
return;
}
for (int i = fromIndex; i < toIndex; i++) {
PlayerTimeTracker.PlayerTimeStatsWithNames stats = statsList.get(i);
String formattedStats = localizationManager.getString(
"playertime.stats.format",
PlayerTimeTracker.formatTime(stats.getTotalTime()),
PlayerTimeTracker.formatTime(stats.last30Days),
PlayerTimeTracker.formatTime(stats.last7Days)
);
player.sendMessage(Text.literal(String.format("§e%s§r: %s", stats.playerName, formattedStats)), false);
} }
MutableText footer = Text.literal(""); MutableText footer = Text.literal("");
if (page > 1) { if (currentPage > 1) {
int finalPage = page;
footer.append(Text.literal(localizationManager.getString("playertime.command.prev_page")) footer.append(Text.literal(localizationManager.getString("playertime.command.prev_page"))
.styled(style -> style.withClickEvent(new ClickEvent( .styled(style -> style.withClickEvent(new ClickEvent(
ClickEvent.Action.RUN_COMMAND, ClickEvent.Action.RUN_COMMAND,
"/onlineTime " + (finalPage - 1) "/onlineTime " + (currentPage - 1)
))) )).withColor(Formatting.GREEN)));
.append(" "));
} }
footer.append(Text.literal(localizationManager.getString("playertime.command.total_players", lines.size()))); if (currentPage > 1 && currentPage < totalPages) {
footer.append(" ");
}
footer.append(Text.literal(localizationManager.getString("playertime.command.total_players", totalEntries)).formatted(Formatting.GRAY));
if (page < totalPages) {
int finalPage1 = page; if (currentPage < totalPages) {
footer.append(" ").append(Text.literal(localizationManager.getString("playertime.command.next_page")) if (currentPage > 1 || totalEntries > 0) {
footer.append(" ");
}
footer.append(Text.literal(localizationManager.getString("playertime.command.next_page"))
.styled(style -> style.withClickEvent(new ClickEvent( .styled(style -> style.withClickEvent(new ClickEvent(
ClickEvent.Action.RUN_COMMAND, ClickEvent.Action.RUN_COMMAND,
"/onlineTime " + (finalPage1 + 1) "/onlineTime " + (currentPage + 1)
)))); )).withColor(Formatting.GREEN)));
} }
player.sendMessage(footer, false); player.sendMessage(footer, false);
} }
// 检查更新
private void checkForUpdates() {
scheduler.submit(() -> {
try {
Optional<ModContainer> modContainer = FabricLoader.getInstance().getModContainer("playertime");
if (modContainer.isEmpty()) {
LOGGER.warn("[在线时间] 无法获取 Mod 容器,跳过更新检查。");
return;
}
Version currentVersion = modContainer.get().getMetadata().getVersion();
String currentVersionString = currentVersion.getFriendlyString();
LOGGER.info("[在线时间] 正在检查更新...");
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
DocumentBuilder builder = factory.newDocumentBuilder();
Document doc = builder.parse(new URL(RSS_FEED_URL).openStream());
doc.getDocumentElement().normalize();
NodeList itemList = doc.getElementsByTagName("item");
if (itemList.getLength() == 0) {
LOGGER.warn("[在线时间] RSS Feed 中没有找到任何发布项。");
return;
}
Element latestItem = (Element) itemList.item(0);
String latestVersionString = latestItem.getElementsByTagName("title").item(0).getTextContent();
String latestVersionLink = latestItem.getElementsByTagName("link").item(0).getTextContent();
LOGGER.info("[在线时间] 当前版本: {}, 最新版本 (RSS): {}", currentVersionString, latestVersionString);
if (isNewerVersion(latestVersionString, currentVersionString)) {
LOGGER.warn("==================================================");
LOGGER.warn("[在线时间] 发现新版本!");
LOGGER.warn("[在线时间] 当前版本: {}", currentVersionString);
LOGGER.warn("[在线时间] 最新版本: {}", latestVersionString);
LOGGER.warn("[在线时间] 下载链接: {}", latestVersionLink);
LOGGER.warn("==================================================");
} else {
LOGGER.info("[在线时间] 当前已是最新版本(或检查失败)。");
}
} catch (javax.xml.parsers.ParserConfigurationException e) {
LOGGER.error("[在线时间] 更新检查失败: XML 解析器配置错误", e);
} catch (org.xml.sax.SAXException e) {
LOGGER.error("[在线时间] 更新检查失败: XML 解析错误", e);
} catch (java.io.IOException e) {
LOGGER.error("[在线时间] 更新检查失败: 网络或文件读取错误", e);
} catch (Exception e) {
LOGGER.error("[在线时间] 更新检查时发生未知错误", e);
}
});
}
// 检查版本号是否比当前版本高
private boolean isNewerVersion(String newVersion, String currentVersion) {
if (newVersion == null || currentVersion == null || newVersion.isEmpty() || currentVersion.isEmpty()) {
return false;
}
String cleanNewVersion = newVersion.toLowerCase().startsWith("v") ? newVersion.substring(1) : newVersion;
String cleanCurrentVersion = currentVersion.toLowerCase().startsWith("v") ? currentVersion.substring(1) : currentVersion;
String[] newParts = cleanNewVersion.split("\\.");
String[] currentParts = cleanCurrentVersion.split("\\.");
int maxLength = Math.max(newParts.length, currentParts.length);
for (int i = 0; i < maxLength; i++) {
int newPart = (i < newParts.length) ? parseIntOrZero(newParts[i]) : 0;
int currentPart = (i < currentParts.length) ? parseIntOrZero(currentParts[i]) : 0;
if (newPart > currentPart) {
return true;
}
if (newPart < currentPart) {
return false;
}
}
return false;
}
// 尝试将字符串转换为整数如果转换失败则返回0
private int parseIntOrZero(String s) {
try {
return Integer.parseInt(s);
} catch (NumberFormatException e) {
return 0;
}
}
} }

View File

@ -3,262 +3,297 @@ package com.example.playertime;
import com.google.gson.*; import com.google.gson.*;
import com.mojang.authlib.GameProfile; import com.mojang.authlib.GameProfile;
import net.minecraft.server.MinecraftServer; import net.minecraft.server.MinecraftServer;
import net.minecraft.server.PlayerManager;
import net.minecraft.server.network.ServerPlayerEntity; import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.util.Uuids;
import java.io.*; import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Instant; import java.time.Instant;
import java.util.*; import java.util.*;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService; import java.util.stream.Collectors;
import java.util.concurrent.Executors;
public class PlayerTimeTracker { public class PlayerTimeTracker {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private final MinecraftServer server; private final MinecraftServer server;
private final Path dataFile; private final Path dataFile;
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private final Map<UUID, PlayerTimeData> playerData = new ConcurrentHashMap<>(); private final Map<UUID, PlayerTimeData> playerData = new ConcurrentHashMap<>();
private final ModConfig config;
public PlayerTimeTracker(MinecraftServer server) { public PlayerTimeTracker(MinecraftServer server, ModConfig config) {
this.server = server; this.server = server;
this.config = config;
this.dataFile = server.getRunDirectory().resolve("player_time_data.json"); this.dataFile = server.getRunDirectory().resolve("player_time_data.json");
loadData();
} }
// 没用了但是先保留吧 public void loadData() {
// private boolean isWhitelisted(String playerName) {
// MinecraftServer server = this.server;
// if (server == null) return false;
//
// return server.getPlayerManager().getWhitelist()
// .isAllowed(server.getUserCache().findByName(playerName).orElse(null));
// }
public void onPlayerJoin(ServerPlayerEntity player) {
PlayerTimeData data = playerData.computeIfAbsent(player.getUuid(), uuid -> new PlayerTimeData());
data.lastLogin = Instant.now().getEpochSecond();
PlayerTimeMod.LOGGER.info("[在线时间] 玩家 {} 加入, 开始计时", player.getName().getString());
}
public void onPlayerLeave(ServerPlayerEntity player) {
PlayerTimeData data = playerData.get(player.getUuid());
if (data != null && data.lastLogin > 0) {
long now = Instant.now().getEpochSecond();
long sessionTime = now - data.lastLogin;
if (sessionTime > 0) {
data.totalTime += sessionTime;
// 维护30天滚动窗口
data.rolling30Days.addPlayTime(now, sessionTime);
data.rolling7Days.addPlayTime(now, sessionTime);
PlayerTimeMod.LOGGER.info("[在线时间] 玩家 {} 离开, 会话时间 {} 秒, 总计时间 {}",
player.getName().getString(), sessionTime, data.totalTime);
} else {
PlayerTimeMod.LOGGER.warn("[在线时间] 玩家 {} 离开, 但计算出的会话时间不正常 ({}), 可能是由于时间同步问题或快速重新连接导致",
player.getName().getString(), sessionTime);
}
data.lastLogin = 0;
saveAsync(player.getUuid());
} else {
PlayerTimeMod.LOGGER.warn("[在线时间] 玩家 {} 已离开但上次登录时间为0或数据不存在跳过会话时间计算。", player.getName().getString());
}
}
public PlayerTimeStats getPlayerStats(UUID uuid) {
PlayerTimeData data = playerData.get(uuid);
if (data == null) {
return null;
}
long now = Instant.now().getEpochSecond();
PlayerTimeStats stats = new PlayerTimeStats();
stats.totalTime = data.totalTime;
// 检查玩家是否当前在线只在在线时才计算
if (data.lastLogin > 0 && server.getPlayerManager().getPlayer(uuid) != null) {
long currentSessionTime = now - data.lastLogin;
if (currentSessionTime > 0) {
stats.totalTime += currentSessionTime;
}
}
stats.last30Days = data.rolling30Days.getTotalTime(now);
stats.last7Days = data.rolling7Days.getTotalTime(now);
return stats;
}
public Map<String, String> getWhitelistedPlayerStats() {
Map<String, String> stats = new LinkedHashMap<>();
long now = Instant.now().getEpochSecond();
// 获取白名单玩家UUID集合
Set<UUID> whitelistUuids = new HashSet<>();
for (String name : server.getPlayerManager().getWhitelist().getNames()) {
server.getUserCache().findByName(name).ifPresent(profile -> {
whitelistUuids.add(profile.getId());
});
}
// 遍历所有已记录玩家数据
playerData.forEach((uuid, data) -> {
// 只处理白名单玩家
if (whitelistUuids.contains(uuid)) {
String playerName = getPlayerName(uuid);
long totalTime = data.totalTime;
// 检查玩家是否当前在线只在在线时才计算
if (data.lastLogin > 0 && server.getPlayerManager().getPlayer(uuid) != null) {
long currentSessionTime = now - data.lastLogin;
if (currentSessionTime > 0) {
totalTime += currentSessionTime;
}
}
long last30DaysTime = data.rolling30Days.getTotalTime(now);
long last7DaysTime = data.rolling7Days.getTotalTime(now);
stats.put(playerName, PlayerTimeMod.getLocalizationManager().getString(
"playertime.stats.format",
formatTime(totalTime),
formatTime(last30DaysTime),
formatTime(last7DaysTime)
));
}
});
return stats;
}
public String getPlayerName(UUID uuid) {
ServerPlayerEntity player = server.getPlayerManager().getPlayer(uuid);
if (player != null) {
return player.getName().getString();
}
Optional<GameProfile> profile = server.getUserCache().getByUuid(uuid);
if (profile.isPresent()) {
return profile.get().getName();
}
return "Unknown";
}
private void loadData() {
if (!Files.exists(dataFile)) { if (!Files.exists(dataFile)) {
PlayerTimeMod.LOGGER.info("[在线时间] 数据文件未找到,跳过加载"); PlayerTimeMod.LOGGER.info("[在线时间] 数据文件未找到 ({}),跳过加载。", dataFile);
return; return;
} }
try (Reader reader = Files.newBufferedReader(dataFile)) { try (Reader reader = Files.newBufferedReader(dataFile, StandardCharsets.UTF_8)) {
JsonElement jsonElement = JsonParser.parseReader(reader); JsonElement jsonElement = JsonParser.parseReader(reader);
if (jsonElement == null || !jsonElement.isJsonObject()) { if (jsonElement == null || !jsonElement.isJsonObject()) {
PlayerTimeMod.LOGGER.warn("[在线时间] 数据文件为空或格式错误,跳过加载"); PlayerTimeMod.LOGGER.warn("[在线时间] 数据文件 ({}) 为空或格式错误,跳过加载。", dataFile);
return; return;
} }
JsonObject root = jsonElement.getAsJsonObject(); JsonObject root = jsonElement.getAsJsonObject();
int resetCount = 0; int resetCount = 0;
int loadedCount = 0;
for (Map.Entry<String, JsonElement> entry : root.entrySet()) { for (Map.Entry<String, JsonElement> entry : root.entrySet()) {
try { try {
UUID uuid = UUID.fromString(entry.getKey()); UUID uuid = UUID.fromString(entry.getKey());
PlayerTimeData data = GSON.fromJson(entry.getValue(), PlayerTimeData.class); PlayerTimeData data = GSON.fromJson(entry.getValue(), PlayerTimeData.class);
// 检查并重置上次登录时间防止服务器崩溃导致时间计算错误
if (data.lastLogin > 0) { if (data.lastLogin > 0) {
PlayerTimeMod.LOGGER.warn( PlayerTimeMod.LOGGER.warn(
"[在线时间] 在数据加载过程中发现玩家{}UUID{}的最后登录时间大于0{}。将其重置为0。", "[在线时间] 在数据加载过程中发现玩家UUID{}的最后登录时间大于0{}。将其重置为0。",
getPlayerName(uuid), uuid, data.lastLogin uuid, data.lastLogin
); );
data.lastLogin = 0; data.lastLogin = 0;
resetCount++; resetCount++;
} }
playerData.put(uuid, data); playerData.put(uuid, data);
loadedCount++;
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
PlayerTimeMod.LOGGER.error("[在线时间] I数据文件中的 UUID 格式无效: " + entry.getKey(), e); PlayerTimeMod.LOGGER.error("[在线时间] 数据文件中的 UUID 格式无效: " + entry.getKey(), e);
} catch (JsonParseException e) { } catch (JsonParseException e) {
PlayerTimeMod.LOGGER.error("[在线时间] 解析玩家数据失败(UUID: " + entry.getKey() + ")", e); PlayerTimeMod.LOGGER.error("[在线时间] 解析玩家数据失败(UUID: " + entry.getKey() + ")", e);
} }
} }
PlayerTimeMod.LOGGER.info("[在线时间] 成功加载了 {} 名玩家的数据,重置了 {} 名玩家的上次登录时间", playerData.size(), resetCount); PlayerTimeMod.LOGGER.info("[在线时间] 成功加载了 {} 名玩家的数据,重置了 {} 名玩家的上次登录时间。", loadedCount, resetCount);
} catch (IOException e) { } catch (IOException e) {
PlayerTimeMod.LOGGER.error("[在线时间] 无法读取玩家在线时间数据文件", e); PlayerTimeMod.LOGGER.error("[在线时间] 无法读取玩家在线时间数据文件 ({})", dataFile, e);
} catch (JsonParseException e) { } catch (JsonParseException e) {
PlayerTimeMod.LOGGER.error("[在线时间] 玩家在线时间数据文件格式错误", e); PlayerTimeMod.LOGGER.error("[在线时间] 玩家在线时间数据文件 ({}) 格式错误", dataFile, e);
} catch (Exception e) { } catch (Exception e) {
PlayerTimeMod.LOGGER.error("[在线时间] 加载玩家在线时间数据时发生未知错误", e); PlayerTimeMod.LOGGER.error("[在线时间] 加载玩家在线时间数据时发生未知错误", e);
} }
} }
public void onPlayerJoin(ServerPlayerEntity player) {
UUID uuid = player.getUuid();
// 检查配置是否白名单
if (config.isWhitelistOnly() && !server.getPlayerManager().isWhitelisted(player.getGameProfile())) {
PlayerTimeMod.LOGGER.debug("[在线时间] 玩家 {} ({}) 不在白名单,且配置为仅记录白名单玩家,跳过加入处理。", player.getName().getString(), uuid);
return;
}
PlayerTimeData data = playerData.computeIfAbsent(uuid, k -> {
PlayerTimeMod.LOGGER.info("[在线时间] 为新玩家 {} ({}) 创建数据。", player.getName().getString(), uuid);
return new PlayerTimeData();
});
// 记录本次登录的开始时间
data.lastLogin = Instant.now().getEpochSecond();
PlayerTimeMod.LOGGER.info("[在线时间] 玩家 {} ({}) 加入, 开始计时。", player.getName().getString(), uuid);
}
public void onPlayerLeave(ServerPlayerEntity player) {
UUID uuid = player.getUuid();
PlayerTimeData data = playerData.get(uuid);
// 防幽灵
if (data != null && data.lastLogin > 0) {
long now = Instant.now().getEpochSecond();
long sessionTime = now - data.lastLogin;
if (sessionTime > 0) {
data.totalTime += sessionTime;
data.rolling30Days.addPlayTime(now, sessionTime);
data.rolling7Days.addPlayTime(now, sessionTime);
PlayerTimeMod.LOGGER.info("[在线时间] 玩家 {} ({}) 离开, 会话时间 {} 秒, 总计时间 {} 秒。",
player.getName().getString(), uuid, sessionTime, data.totalTime);
} else {
// 防其他因素
PlayerTimeMod.LOGGER.warn("[在线时间] 玩家 {} ({}) 离开, 但计算出的会话时间不正常 ({}), 跳过时间累加。",
player.getName().getString(), uuid, sessionTime);
}
// 标记离线
data.lastLogin = 0;
} else {
PlayerTimeMod.LOGGER.debug("[在线时间] 玩家 {} ({}) 离开但数据不存在或上次登录时间为0跳过会话时间计算。", player.getName().getString(), uuid);
}
}
public boolean isPlayerTracked(UUID uuid) {
return playerData.containsKey(uuid);
}
public List<PlayerTimeStatsWithNames> getSortedPlayerStats() {
long now = Instant.now().getEpochSecond();
List<PlayerTimeStatsWithNames> statsList = new ArrayList<>();
// 获取白名单 UUID 集合 (如果需要过滤)
Set<UUID> whitelistUuids;
if (config.isWhitelistOnly()) {
whitelistUuids = new HashSet<>();
if (server != null && server.getPlayerManager() != null && server.getUserCache() != null) {
for (String name : server.getPlayerManager().getWhitelist().getNames()) {
server.getUserCache().findByName(name).ifPresent(profile -> {
whitelistUuids.add(profile.getId());
});
}
} else {
PlayerTimeMod.LOGGER.warn("[在线时间] UserCache 或 PlayerManager 不可用,无法获取白名单列表进行过滤!");
// 异常处理返回空列表
return Collections.emptyList();
}
} else {
whitelistUuids = null;
}
for (Map.Entry<UUID, PlayerTimeData> entry : playerData.entrySet()) {
UUID uuid = entry.getKey();
PlayerTimeData data = entry.getValue();
// 是不是白名单玩家
if (config.isWhitelistOnly() && (whitelistUuids == null || !whitelistUuids.contains(uuid))) {
continue;
}
// 计算当前总时间
long currentTotalTime = data.totalTime;
// 只有当玩家在线时才计算当前会话时间
if (data.lastLogin > 0 && server.getPlayerManager().getPlayer(uuid) != null) {
long currentSessionTime = now - data.lastLogin;
if (currentSessionTime > 0) {
currentTotalTime += currentSessionTime;
}
}
// 获取滚动时间
long last30DaysTime = data.rolling30Days.getTotalTime(now);
long last7DaysTime = data.rolling7Days.getTotalTime(now);
// 获取玩家名称
String playerName = getPlayerName(uuid);
statsList.add(new PlayerTimeStatsWithNames(uuid, playerName, currentTotalTime, last30DaysTime, last7DaysTime));
}
// 按总时排序
statsList.sort(Comparator.comparingLong(PlayerTimeStatsWithNames::getTotalTime).reversed());
return statsList;
}
public String getPlayerName(UUID uuid) {
// 尝试获取在线玩家名称
ServerPlayerEntity player = server.getPlayerManager().getPlayer(uuid);
if (player != null) {
return player.getName().getString();
}
// 尝试从 UserCache 获取名称主要用于离线服务器
if (server != null && server.getUserCache() != null) {
Optional<GameProfile> profile = server.getUserCache().getByUuid(uuid);
if (profile.isPresent()) {
return profile.get().getName();
}
} else {
PlayerTimeMod.LOGGER.debug("[在线时间] 尝试通过 UUID 获取玩家名称时 UserCache 不可用: {}", uuid);
}
return uuid.toString();
}
public void saveAll() { public void saveAll() {
PlayerTimeMod.LOGGER.info("[在线时间] 开始保存所有玩家数据..."); PlayerTimeMod.LOGGER.info("[在线时间] 开始保存所有玩家数据...");
long now = Instant.now().getEpochSecond();
JsonObject root = new JsonObject(); JsonObject root = new JsonObject();
playerData.forEach((uuid, data) -> { int savedCount = 0;
if (data.lastLogin > 0) {
long now = Instant.now().getEpochSecond(); for (ServerPlayerEntity player : server.getPlayerManager().getPlayerList()) {
UUID uuid = player.getUuid();
PlayerTimeData data = playerData.get(uuid);
// 只有被跟踪且没标记离线
if (data != null && data.lastLogin > 0) {
long sessionTime = now - data.lastLogin; long sessionTime = now - data.lastLogin;
if (sessionTime > 0) { if (sessionTime > 0) {
data.totalTime += sessionTime; data.totalTime += sessionTime;
data.rolling30Days.addPlayTime(now, sessionTime); data.rolling30Days.addPlayTime(now, sessionTime);
data.rolling7Days.addPlayTime(now, sessionTime); data.rolling7Days.addPlayTime(now, sessionTime);
PlayerTimeMod.LOGGER.debug("[在线时间] 累积当前会话时间 {} 秒,保存期间玩家 {}", sessionTime, getPlayerName(uuid)); // 更新 lastLogin 到当前时间防止炸鱼
} data.lastLogin = now;
data.lastLogin = 0; PlayerTimeMod.LOGGER.debug("[在线时间] 定时保存:更新玩家 {} ({}) 会话时间 {} 秒。", player.getName().getString(), uuid, sessionTime);
}
root.add(uuid.toString(), GSON.toJsonTree(data));
});
try (Writer writer = Files.newBufferedWriter(dataFile)) {
GSON.toJson(root, writer);
PlayerTimeMod.LOGGER.info("[在线时间] 所有玩家数据已成功保存");
} catch (Exception e) {
PlayerTimeMod.LOGGER.error("[在线时间] 无法保存所有玩家在线时间数据", e);
}
}
private void saveAsync(UUID uuid) {
executor.execute(() -> {
PlayerTimeData data = playerData.get(uuid);
if (data == null) {
return;
}
JsonObject root;
try {
if (Files.exists(dataFile)) {
try (Reader reader = Files.newBufferedReader(dataFile)) {
JsonElement jsonElement = JsonParser.parseReader(reader);
if (jsonElement != null && jsonElement.isJsonObject()) {
root = jsonElement.getAsJsonObject();
} else {
root = new JsonObject();
}
}
} else { } else {
root = new JsonObject(); PlayerTimeMod.LOGGER.debug("[在线时间] 定时保存:玩家 {} ({}) 会话时间不正常 ({}),跳过更新。", player.getName().getString(), uuid, sessionTime);
} }
} catch (Exception e) { }
PlayerTimeMod.LOGGER.error("[在线时间] 在异步保存期间无法读取数据文件,正在创建新对象", e); }
root = new JsonObject();
// 遍历所有玩家数据进行保存
for (Map.Entry<UUID, PlayerTimeData> entry : playerData.entrySet()) {
UUID uuid = entry.getKey();
PlayerTimeData data = entry.getValue();
boolean isWhitelisted = false;
if (server != null && server.getPlayerManager() != null) {
// 优先检查在线玩家
ServerPlayerEntity onlinePlayer = server.getPlayerManager().getPlayer(uuid);
if (onlinePlayer != null) {
isWhitelisted = server.getPlayerManager().isWhitelisted(onlinePlayer.getGameProfile());
} else if (server.getUserCache() != null) {
// 检查离线玩家是否在白名单
Optional<GameProfile> profile = server.getUserCache().getByUuid(uuid);
if (profile.isPresent()) {
isWhitelisted = server.getPlayerManager().isWhitelisted(profile.get());
}
}
} else {
PlayerTimeMod.LOGGER.warn("[在线时间] UserCache 或 PlayerManager 不可用,无法在保存时检查白名单状态。");
// 如果无法检查白名单且配置为只记录白名单则跳过保存此玩家数据
if (config.isWhitelistOnly()) {
PlayerTimeMod.LOGGER.debug("[在线时间] 无法检查白名单状态,且配置为仅记录白名单,跳过保存玩家 {} ({}) 的数据。", getPlayerName(uuid), uuid);
continue;
}
}
if (config.isWhitelistOnly() && !isWhitelisted) {
PlayerTimeMod.LOGGER.debug("[在线时间] 配置为仅记录白名单,跳过保存非白名单玩家 {} ({}) 的数据。", getPlayerName(uuid), uuid);
continue;
} }
root.add(uuid.toString(), GSON.toJsonTree(data)); root.add(uuid.toString(), GSON.toJsonTree(data));
savedCount++;
}
try (Writer writer = Files.newBufferedWriter(dataFile)) {
GSON.toJson(root, writer); try {
// PlayerTimeMod.LOGGER.debug("[在线时间] Async save successful for player {}", getPlayerName(uuid)); Path dataDir = dataFile.getParent();
} catch (Exception e) {
PlayerTimeMod.LOGGER.error("[在线时间] 无法异步保存玩家的在线时间数据 " + getPlayerName(uuid) + " (UUID: " + uuid + ")", e); if (dataDir != null) {
Files.createDirectories(dataDir);
} else {
PlayerTimeMod.LOGGER.debug("[在线时间] 数据文件 {} 位于根目录,跳过创建父目录。", dataFile);
} }
});
Path tempFile = dataFile.resolveSibling(dataFile.getFileName().toString() + ".tmp");
try (Writer writer = Files.newBufferedWriter(tempFile, StandardCharsets.UTF_8)) {
GSON.toJson(root, writer);
}
Files.move(tempFile, dataFile, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
PlayerTimeMod.LOGGER.info("[在线时间] 所有玩家数据 ({}) 已成功保存,共 {} 条记录。", dataFile, savedCount);
} catch (Exception e) {
PlayerTimeMod.LOGGER.error("[在线时间] 无法保存所有玩家在线时间数据到 {}", dataFile, e);
}
} }
public static String formatTime(long seconds) { public static String formatTime(long seconds) {
@ -269,48 +304,56 @@ public class PlayerTimeTracker {
} }
public static class PlayerTimeData { public static class PlayerTimeData {
long totalTime = 0; long totalTime = 0; // 总在线时间
long lastLogin = 0; long lastLogin = 0; // 上次登录时间戳
RollingTimeWindow rolling30Days = new RollingTimeWindow(30); RollingTimeWindow rolling30Days = new RollingTimeWindow(30); // 最近30天
RollingTimeWindow rolling7Days = new RollingTimeWindow(7); RollingTimeWindow rolling7Days = new RollingTimeWindow(7); // 最近7天
}
public static class PlayerTimeStats {
public long totalTime;
public long last30Days;
public long last7Days;
} }
private static class RollingTimeWindow { private static class RollingTimeWindow {
private final int days; private final int days;
private final List<TimeEntry> entries = new LinkedList<>(); private final List<TimeEntry> entries = new LinkedList<>();
@SuppressWarnings("unused")
private RollingTimeWindow() {
this.days = 0;
}
public RollingTimeWindow(int days) { public RollingTimeWindow(int days) {
this.days = days; this.days = days;
} }
public void addPlayTime(long timestamp, long seconds) { public void addPlayTime(long timestamp, long seconds) {
if (seconds <= 0) return; if (seconds <= 0) return;
entries.add(new TimeEntry(timestamp, seconds)); entries.add(new TimeEntry(timestamp, seconds));
cleanUp(timestamp); cleanUp(timestamp);
} }
public long getTotalTime(long currentTime) { public long getTotalTime(long currentTime) {
cleanUp(currentTime); cleanUp(currentTime);
return entries.stream().mapToLong(e -> e.seconds).sum(); return entries.stream().mapToLong(e -> e.seconds).sum();
} }
private void cleanUp(long currentTime) { private void cleanUp(long currentTime) {
long cutoff = currentTime - (days * 24 * 3600L); long cutoff = currentTime - (days * 24 * 3600L);
while (!entries.isEmpty() && entries.get(0).timestamp < cutoff) { entries.removeIf(entry -> entry.timestamp < cutoff);
entries.remove(0);
}
} }
private static class TimeEntry { private static class TimeEntry {
final long timestamp; final long timestamp;
final long seconds; final long seconds;
// Gson 需要一个无参构造函数
@SuppressWarnings("unused")
private TimeEntry() {
this.timestamp = 0;
this.seconds = 0;
}
TimeEntry(long timestamp, long seconds) { TimeEntry(long timestamp, long seconds) {
this.timestamp = timestamp; this.timestamp = timestamp;
this.seconds = seconds; this.seconds = seconds;
@ -318,6 +361,30 @@ public class PlayerTimeTracker {
} }
} }
public static class PlayerTimeStatsWithNames {
public UUID uuid;
public String playerName;
private long totalTime;
public long last30Days;
public long last7Days;
@SuppressWarnings("unused")
private PlayerTimeStatsWithNames() {}
public PlayerTimeStatsWithNames(UUID uuid, String playerName, long totalTime, long last30Days, long last7Days) {
this.uuid = uuid;
this.playerName = playerName;
this.totalTime = totalTime;
this.last30Days = last30Days;
this.last7Days = last7Days;
}
public long getTotalTime() {
return totalTime;
}
}
public Map<UUID, PlayerTimeData> getPlayerData() { public Map<UUID, PlayerTimeData> getPlayerData() {
return Collections.unmodifiableMap(playerData); return Collections.unmodifiableMap(playerData);
} }
@ -326,4 +393,103 @@ public class PlayerTimeTracker {
return this.dataFile; return this.dataFile;
} }
public Map<String, List<Map<String, String>>> getOnlinePlayersCategorized() {
List<Map<String, String>> whitelisted = new ArrayList<>();
List<Map<String, String>> nonWhitelisted = new ArrayList<>();
PlayerManager playerManager = server.getPlayerManager();
Set<UUID> whitelistUuids = new HashSet<>();
if (server != null && server.getUserCache() != null) {
for (String name : playerManager.getWhitelist().getNames()) {
server.getUserCache().findByName(name).ifPresent(profile -> {
whitelistUuids.add(profile.getId());
});
}
} else {
PlayerTimeMod.LOGGER.warn("[在线时间] UserCache 不可用无法获取白名单UUID用于在线玩家分类。");
}
for (ServerPlayerEntity player : playerManager.getPlayerList()) {
Map<String, String> playerInfo = new HashMap<>();
playerInfo.put("name", player.getName().getString());
playerInfo.put("uuid", player.getUuid().toString());
if (whitelistUuids.contains(player.getUuid())) {
whitelisted.add(playerInfo);
} else {
nonWhitelisted.add(playerInfo);
}
}
Map<String, List<Map<String, String>>> result = new HashMap<>();
result.put("whitelisted", whitelisted);
result.put("non_whitelisted", nonWhitelisted);
return result;
}
public Map<String, Integer> getOnlinePlayerCounts() {
int total = 0;
int whitelisted = 0;
int nonWhitelisted = 0;
PlayerManager playerManager = server.getPlayerManager();
Set<UUID> whitelistUuids = new HashSet<>();
if (server != null && server.getUserCache() != null) {
for (String name : playerManager.getWhitelist().getNames()) {
server.getUserCache().findByName(name).ifPresent(profile -> {
whitelistUuids.add(profile.getId());
});
}
} else {
PlayerTimeMod.LOGGER.warn("[在线时间] UserCache 不可用无法获取白名单UUID用于玩家计数分类。");
}
for (ServerPlayerEntity player : playerManager.getPlayerList()) {
total++;
if (whitelistUuids.contains(player.getUuid())) {
whitelisted++;
} else {
nonWhitelisted++;
}
}
Map<String, Integer> result = new HashMap<>();
result.put("total", total);
result.put("whitelisted", whitelisted);
result.put("non_whitelisted", nonWhitelisted);
return result;
}
public List<Map<String, Object>> getWhitelistPlayers() {
List<Map<String, Object>> whitelist = new ArrayList<>();
PlayerManager playerManager = server.getPlayerManager();
if (server != null && server.getUserCache() != null) {
for (String name : playerManager.getWhitelist().getNames()) {
Map<String, Object> player = new HashMap<>();
player.put("name", name);
Optional<GameProfile> profile = server.getUserCache().findByName(name);
if (profile.isPresent()) {
UUID uuid = profile.get().getId();
player.put("uuid", uuid.toString());
ServerPlayerEntity onlinePlayer = playerManager.getPlayer(uuid);
player.put("online", onlinePlayer != null);
} else {
UUID offlineUuid = Uuids.getOfflinePlayerUuid(name);
player.put("uuid", offlineUuid.toString());
ServerPlayerEntity onlinePlayer = playerManager.getPlayer(offlineUuid);
player.put("online", onlinePlayer != null);
}
whitelist.add(player);
}
} else {
PlayerTimeMod.LOGGER.warn("[在线时间] UserCache 不可用,无法获取白名单列表。");
}
return whitelist;
}
} }

View File

@ -1,7 +1,6 @@
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;
@ -20,6 +19,12 @@ import java.util.*;
import java.util.concurrent.*; import java.util.concurrent.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
// 导入API类别忘了
import com.example.playertime.api.PlayerStatsResponse;
import com.example.playertime.api.ServerStatusResponse;
import com.example.playertime.api.OnlinePlayersResponse;
import com.example.playertime.api.PlayerCountResponse;
import com.example.playertime.api.WhitelistResponse;
public class WebServer { public class WebServer {
@ -28,12 +33,13 @@ public class WebServer {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private final ExecutorService executor = Executors.newFixedThreadPool(4); private final ExecutorService executor = Executors.newFixedThreadPool(4);
private final MinecraftServer minecraftServer; private final MinecraftServer minecraftServer;
private static final Map<String, String> MIME_TYPES = Map.ofEntries( private static final Map<String, String> MIME_TYPES = Map.ofEntries(
Map.entry("html", "text/html"), Map.entry("html", "text/html"),
Map.entry("css", "text/css"), Map.entry("css", "text/css"),
Map.entry("js", "application/javascript"), Map.entry("js", "application/javascript"),
Map.entry("json", "application/json"), Map.entry("json", "application/json"),
Map.entry("png", "image/png"), // Added common web asset types Map.entry("png", "image/png"),
Map.entry("jpg", "image/jpeg"), Map.entry("jpg", "image/jpeg"),
Map.entry("jpeg", "image/jpeg"), Map.entry("jpeg", "image/jpeg"),
Map.entry("gif", "image/gif"), Map.entry("gif", "image/gif"),
@ -41,7 +47,8 @@ public class WebServer {
Map.entry("woff", "application/font-woff"), Map.entry("woff", "application/font-woff"),
Map.entry("woff2", "application/font-woff2"), Map.entry("woff2", "application/font-woff2"),
Map.entry("ttf", "application/font-sfnt"), Map.entry("ttf", "application/font-sfnt"),
Map.entry("eot", "application/vnd.ms-fontobject") Map.entry("eot", "application/vnd.ms-fontobject"),
Map.entry("ico", "image/x-icon")
); );
public WebServer(PlayerTimeTracker timeTracker, int port, MinecraftServer minecraftServer) throws IOException { public WebServer(PlayerTimeTracker timeTracker, int port, MinecraftServer minecraftServer) throws IOException {
@ -54,7 +61,9 @@ public class WebServer {
setupContexts(); setupContexts();
} }
// 创建HTTP服务器
private void setupContexts() { private void setupContexts() {
// API: 获取玩家统计数据
server.createContext("/api/stats", exchange -> { server.createContext("/api/stats", exchange -> {
handleCors(exchange); handleCors(exchange);
if ("OPTIONS".equals(exchange.getRequestMethod())) { if ("OPTIONS".equals(exchange.getRequestMethod())) {
@ -68,17 +77,31 @@ public class WebServer {
} }
try { try {
// 白名单 List<PlayerTimeTracker.PlayerTimeStatsWithNames> statsList = timeTracker.getSortedPlayerStats();
Map<String, String> stats = timeTracker.getWhitelistedPlayerStats();
String response = GSON.toJson(stats); List<PlayerStatsResponse> responseList = statsList.stream()
.map(stats -> new PlayerStatsResponse(
stats.playerName,
stats.uuid.toString(),
stats.getTotalTime(),
PlayerTimeTracker.formatTime(stats.getTotalTime()),
stats.last30Days,
PlayerTimeTracker.formatTime(stats.last30Days),
stats.last7Days,
PlayerTimeTracker.formatTime(stats.last7Days)
))
.collect(Collectors.toList());
String response = GSON.toJson(responseList);
sendResponse(exchange, 200, response.getBytes(StandardCharsets.UTF_8), "application/json"); sendResponse(exchange, 200, response.getBytes(StandardCharsets.UTF_8), "application/json");
PlayerTimeMod.LOGGER.debug("[在线时间] API /api/stats 请求成功,返回 {} 条玩家数据。", responseList.size());
} catch (Exception e) { } catch (Exception e) {
PlayerTimeMod.LOGGER.error("[PlayerTime] Failed to get stats data", e); PlayerTimeMod.LOGGER.error("[在线时间] 处理 /api/stats 请求失败", e);
sendResponse(exchange, 500, "Internal Server Error"); sendResponse(exchange, 500, "Internal Server Error");
} }
}); });
// 语言文件内容 // API: 获取语言文件内容不公开
server.createContext("/api/lang", exchange -> { server.createContext("/api/lang", exchange -> {
handleCors(exchange); handleCors(exchange);
if ("OPTIONS".equals(exchange.getRequestMethod())) { if ("OPTIONS".equals(exchange.getRequestMethod())) {
@ -92,62 +115,194 @@ public class WebServer {
} }
try { try {
String langCode = PlayerTimeMod.getConfig().getLanguage(); Map<String, String> langMap = PlayerTimeMod.getLocalizationManager().getAllStrings();
String resourcePath = String.format("assets/playertime/lang/%s.json", langCode); String response = GSON.toJson(langMap);
sendResponse(exchange, 200, response.getBytes(StandardCharsets.UTF_8), "application/json");
PlayerTimeMod.LOGGER.debug("[在线时间] API /api/lang 请求成功,返回 {} 条语言字符串。", langMap.size());
} catch (Exception e) {
PlayerTimeMod.LOGGER.error("[在线时间] 处理 /api/lang 请求失败", e);
sendResponse(exchange, 500, "Internal Server Error");
}
});
String finalResourcePath1 = resourcePath; // API: 获取在线玩家列表
InputStream is = FabricLoader.getInstance().getModContainer("playertime") server.createContext("/api/online-players", exchange -> {
.flatMap(container -> container.findPath(finalResourcePath1)) handleCors(exchange);
.map(path -> { if ("OPTIONS".equals(exchange.getRequestMethod())) {
try { exchange.sendResponseHeaders(204, -1);
return path.toUri().toURL().openStream(); return;
} catch (Exception e) { }
return null;
}
})
.orElse(null);
if (is == null) { if (!"GET".equals(exchange.getRequestMethod())) {
// Fallback to default language if configured language file is not found sendResponse(exchange, 405, "Method Not Allowed");
langCode = "en_us"; // Default fallback return;
resourcePath = String.format("assets/playertime/lang/%s.json", langCode); }
String finalResourcePath = resourcePath;
is = FabricLoader.getInstance().getModContainer("playertime")
.flatMap(container -> container.findPath(finalResourcePath))
.map(path -> {
try {
return path.toUri().toURL().openStream();
} catch (Exception e) {
return null;
}
})
.orElse(null);
if (is == null) { try {
PlayerTimeMod.LOGGER.error("[PlayerTime] Default language file (en_us.json) not found!"); Map<String, List<Map<String, String>>> onlinePlayers = timeTracker.getOnlinePlayersCategorized();
sendResponse(exchange, 500, "Language file not found"); OnlinePlayersResponse response = new OnlinePlayersResponse(onlinePlayers.get("whitelisted"), onlinePlayers.get("non_whitelisted"));
return; sendResponse(exchange, 200, GSON.toJson(response).getBytes(StandardCharsets.UTF_8), "application/json");
} PlayerTimeMod.LOGGER.debug("[在线时间] API /api/online-players 请求成功。");
PlayerTimeMod.LOGGER.warn("[PlayerTime] Configured language file ({}.json) not found, using default (en_us.json).", PlayerTimeMod.getConfig().getLanguage()); } catch (Exception e) {
PlayerTimeMod.LOGGER.error("[在线时间] 处理 /api/online-players 请求失败", e);
sendResponse(exchange, 500, "Internal Server Error");
}
});
// API: 获取在线玩家数量
server.createContext("/api/player-count", exchange -> {
handleCors(exchange);
if ("OPTIONS".equals(exchange.getRequestMethod())) {
exchange.sendResponseHeaders(204, -1);
return;
}
if (!"GET".equals(exchange.getRequestMethod())) {
sendResponse(exchange, 405, "Method Not Allowed");
return;
}
try {
Map<String, Integer> counts = timeTracker.getOnlinePlayerCounts();
PlayerCountResponse response = new PlayerCountResponse(counts.get("total"), counts.get("whitelisted"), counts.get("non_whitelisted"));
sendResponse(exchange, 200, GSON.toJson(response).getBytes(StandardCharsets.UTF_8), "application/json");
PlayerTimeMod.LOGGER.debug("[在线时间] API /api/player-count 请求成功。");
} catch (Exception e) {
PlayerTimeMod.LOGGER.error("[在线时间] 处理 /api/player-count 请求失败", e);
sendResponse(exchange, 500, "Internal Server Error");
}
});
// API: 获取白名单列表
server.createContext("/api/whitelist", exchange -> {
handleCors(exchange);
if ("OPTIONS".equals(exchange.getRequestMethod())) {
exchange.sendResponseHeaders(204, -1);
return;
}
if (!"GET".equals(exchange.getRequestMethod())) {
sendResponse(exchange, 405, "Method Not Allowed");
return;
}
try {
List<Map<String, Object>> whitelist = timeTracker.getWhitelistPlayers();
List<WhitelistResponse> responseList = whitelist.stream()
.map(map -> new WhitelistResponse((String)map.get("name"), (String)map.get("uuid"), (Boolean)map.get("online")))
.collect(Collectors.toList());
sendResponse(exchange, 200, GSON.toJson(responseList).getBytes(StandardCharsets.UTF_8), "application/json");
PlayerTimeMod.LOGGER.debug("[在线时间] API /api/whitelist 请求成功,返回 {} 条白名单记录。", responseList.size());
} catch (Exception e) {
PlayerTimeMod.LOGGER.error("[在线时间] 处理 /api/whitelist 请求失败", e);
sendResponse(exchange, 500, "Internal Server Error");
}
});
// API: 获取服务器状态
server.createContext("/api/server-status", exchange -> {
handleCors(exchange);
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();
ServerStatusResponse status = new ServerStatusResponse();
long maxMemory = runtime.maxMemory();
long totalMemory = runtime.totalMemory();
long freeMemory = runtime.freeMemory();
long usedMemory = totalMemory - freeMemory;
status.memory.max = maxMemory;
status.memory.total = totalMemory;
status.memory.used = usedMemory;
status.memory.free = freeMemory;
status.memory.usage_percentage = (double) usedMemory / maxMemory * 100;
status.available_processors = runtime.availableProcessors();
long uptimeMillis = ManagementFactory.getRuntimeMXBean().getUptime();
status.uptime = uptimeMillis / 1000; //
status.uptime_formatted = formatUptime(uptimeMillis);
try {
File diskPartition = new File(".");
status.disk.total = diskPartition.getTotalSpace();
status.disk.free = diskPartition.getFreeSpace();
status.disk.usable = diskPartition.getUsableSpace();
status.disk.usage_percentage = (double) (status.disk.total - status.disk.free) / status.disk.total * 100;
} catch (Exception e) {
PlayerTimeMod.LOGGER.warn("[在线时间] 无法获取磁盘信息", e);
} }
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
byte[] data = new byte[1024];
int nRead;
while ((nRead = is.read(data, 0, data.length)) != -1) {
buffer.write(data, 0, nRead);
}
buffer.flush();
is.close();
sendResponse(exchange, 200, buffer.toByteArray(), "application/json"); status.server.version = minecraftServer.getVersion();
PlayerTimeMod.LOGGER.debug("[PlayerTime] Served language file: {}", resourcePath); status.server.player_count = minecraftServer.getCurrentPlayerCount();
status.server.max_players = minecraftServer.getMaxPlayerCount();
status.server.motd = minecraftServer.getServerMotd();
status.server.average_tick_time_ms = minecraftServer.getAverageTickTime();
long[] tickTimes = minecraftServer.getTickTimes();
if (tickTimes != null && tickTimes.length > 0) {
long[] recentTicksCopy = Arrays.copyOf(tickTimes, tickTimes.length);
status.server.recent_tick_samples_ms = Arrays.stream(recentTicksCopy)
.mapToDouble(tick -> tick / 1000000.0)
.boxed()
.collect(Collectors.toList());
} else {
status.server.recent_tick_samples_ms = Collections.emptyList();
}
sendResponse(exchange, 200, GSON.toJson(status).getBytes(StandardCharsets.UTF_8), "application/json");
PlayerTimeMod.LOGGER.debug("[在线时间] API /api/server-status 请求成功。");
} catch (Exception e) {
PlayerTimeMod.LOGGER.error("[在线时间] 处理 /api/server-status 请求失败", e);
sendResponse(exchange, 500, "Internal Server Error");
}
});
// API: 获取原始玩家数据文件内容建议最好别公开
server.createContext("/api/playerdata", exchange -> {
handleCors(exchange);
if ("OPTIONS".equals(exchange.getRequestMethod())) {
exchange.sendResponseHeaders(204, -1);
return;
}
if (!"GET".equals(exchange.getRequestMethod())) {
sendResponse(exchange, 405, "Method Not Allowed");
return;
}
try {
Path dataFile = timeTracker.getDataFile();
if (!Files.exists(dataFile)) {
PlayerTimeMod.LOGGER.warn("[在线时间] 玩家数据文件未找到: {}", dataFile);
sendResponse(exchange, 404, "Data file not found");
return;
}
byte[] fileContent = Files.readAllBytes(dataFile);
sendResponse(exchange, 200, fileContent, "application/json");
PlayerTimeMod.LOGGER.debug("[在线时间] 成功提供玩家数据文件 {}", dataFile);
} catch (IOException e) { } catch (IOException e) {
PlayerTimeMod.LOGGER.error("[PlayerTime] Failed to read language file", e); PlayerTimeMod.LOGGER.error("[在线时间] 读取玩家数据文件时出错", e);
sendResponse(exchange, 500, "Error reading language file"); sendResponse(exchange, 500, "Error reading data file");
} catch (Exception e) { } catch (Exception e) {
PlayerTimeMod.LOGGER.error("[PlayerTime] An unknown error occurred while processing language request", e); PlayerTimeMod.LOGGER.error("[在线时间] 处理 /api/playerdata 请求时发生未知错误", e);
sendResponse(exchange, 500, "Internal Server Error"); sendResponse(exchange, 500, "Internal Server Error");
} }
}); });
@ -157,19 +312,16 @@ public class WebServer {
server.createContext("/", exchange -> { server.createContext("/", exchange -> {
try { try {
String requestPath = exchange.getRequestURI().getPath(); String requestPath = exchange.getRequestURI().getPath();
String resourceFileName;
if (requestPath.equals("/")) {
resourceFileName = "index.html";
} else {
int lastSlash = requestPath.lastIndexOf('/');
resourceFileName = requestPath.substring(lastSlash + 1);
}
String resourcePath = "assets/playertime/web" + requestPath; String resourcePath = "assets/playertime/web" + requestPath;
if (requestPath.equals("/")) { if (requestPath.equals("/")) {
resourcePath += resourceFileName; resourcePath += "index.html";
} }
if (resourcePath.contains("..")) {
sendResponse(exchange, 403, "Forbidden");
return;
}
String finalResourcePath = resourcePath; String finalResourcePath = resourcePath;
InputStream is = FabricLoader.getInstance().getModContainer("playertime") InputStream is = FabricLoader.getInstance().getModContainer("playertime")
@ -185,22 +337,39 @@ public class WebServer {
if (is == null) { if (is == null) {
PlayerTimeMod.LOGGER.warn("[PlayerTime] Static resource not found: {}", resourcePath); if (!requestPath.equals("/") && !requestPath.contains(".")) {
sendResponse(exchange, 404, "Not Found"); PlayerTimeMod.LOGGER.debug("[在线时间] 找不到静态资源:{},尝试提供 index.html 作为 fallback。", resourcePath);
return; resourcePath = "assets/playertime/web/index.html";
String finalResourcePath1 = resourcePath;
is = FabricLoader.getInstance().getModContainer("playertime")
.flatMap(container -> container.findPath(finalResourcePath1))
.map(p -> {
try {
return p.toUri().toURL().openStream();
} catch (Exception e) {
return null;
}
})
.orElse(null);
}
if (is == null) {
PlayerTimeMod.LOGGER.warn("[在线时间] 找不到静态资源: {}", finalResourcePath);
sendResponse(exchange, 404, "Not Found");
return;
}
} }
// 确定内容类型一层保险 String fileName = Paths.get(resourcePath).getFileName().toString();
String extension = ""; String extension = "";
int dotIndex = resourceFileName.lastIndexOf('.'); int dotIndex = fileName.lastIndexOf('.');
if (dotIndex > 0 && dotIndex < resourceFileName.length() - 1) { if (dotIndex > 0 && dotIndex < fileName.length() - 1) {
extension = resourceFileName.substring(dotIndex + 1).toLowerCase(); extension = fileName.substring(dotIndex + 1).toLowerCase();
} }
String contentType = MIME_TYPES.getOrDefault(extension, "application/octet-stream"); String contentType = MIME_TYPES.getOrDefault(extension, "application/octet-stream");
// 读取文件内容
ByteArrayOutputStream buffer = new ByteArrayOutputStream(); ByteArrayOutputStream buffer = new ByteArrayOutputStream();
byte[] data = new byte[1024]; byte[] data = new byte[4096];
int nRead; int nRead;
while ((nRead = is.read(data, 0, data.length)) != -1) { while ((nRead = is.read(data, 0, data.length)) != -1) {
buffer.write(data, 0, nRead); buffer.write(data, 0, nRead);
@ -209,334 +378,10 @@ public class WebServer {
is.close(); is.close();
sendResponse(exchange, 200, buffer.toByteArray(), contentType); sendResponse(exchange, 200, buffer.toByteArray(), contentType);
PlayerTimeMod.LOGGER.debug("[PlayerTime] Served static file: {}", resourcePath); PlayerTimeMod.LOGGER.debug("[在线时间] 提供静态文件: {}", resourcePath);
} catch (Exception e) { } catch (Exception e) {
PlayerTimeMod.LOGGER.error("[PlayerTime] Failed to serve static resource", e); PlayerTimeMod.LOGGER.error("[在线时间] 无法提供静态资源", e);
sendResponse(exchange, 500, "Internal Server Error");
}
});
// 没啥用了
server.createContext("/api/widget-data", exchange -> {
handleCors(exchange);
if ("OPTIONS".equals(exchange.getRequestMethod())) {
exchange.sendResponseHeaders(204, -1);
return;
}
if (!"GET".equals(exchange.getRequestMethod())) {
sendResponse(exchange, 405, "Method Not Allowed");
return;
}
try {
MinecraftServer server = minecraftServer;
PlayerManager playerManager = server.getPlayerManager();
JsonObject response = new JsonObject();
response.addProperty("onlineCount", playerManager.getCurrentPlayerCount());
JsonArray whitelistPlayers = new JsonArray();
Set<UUID> whitelistUuids = new HashSet<>();
for (String name : playerManager.getWhitelist().getNames()) {
server.getUserCache().findByName(name).ifPresent(profile -> {
whitelistUuids.add(profile.getId());
});
}
for (ServerPlayerEntity player : playerManager.getPlayerList()) {
UUID uuid = player.getUuid();
if (whitelistUuids.contains(uuid)) {
PlayerTimeTracker.PlayerTimeStats stats = timeTracker.getPlayerStats(uuid);
if (stats != null) {
JsonObject playerJson = new JsonObject();
playerJson.addProperty("name", player.getName().getString());
playerJson.addProperty("time", PlayerTimeTracker.formatTime(stats.totalTime));
whitelistPlayers.add(playerJson);
}
}
}
response.add("whitelistPlayers", whitelistPlayers);
JsonArray topPlayers = new JsonArray();
timeTracker.getPlayerData().entrySet().stream()
.filter(entry -> whitelistUuids.contains(entry.getKey())) // 只筛选白名单玩家
.sorted((a, b) -> Long.compare(b.getValue().totalTime, a.getValue().totalTime))
.limit(3)
.forEach(entry -> {
JsonObject playerJson = new JsonObject();
playerJson.addProperty("name", timeTracker.getPlayerName(entry.getKey()));
playerJson.addProperty("time", PlayerTimeTracker.formatTime(entry.getValue().totalTime)); // formatTime doesn't need localization
topPlayers.add(playerJson);
});
response.add("topPlayers", topPlayers);
response.addProperty("timestamp", System.currentTimeMillis());
sendResponse(exchange, 200, GSON.toJson(response).getBytes(StandardCharsets.UTF_8), "application/json");
} catch (Exception e) {
PlayerTimeMod.LOGGER.error("[PlayerTime] Failed to get widget data", e);
sendResponse(exchange, 500, "Internal Server Error");
}
});
// 在线玩家列表
server.createContext("/api/online-players", exchange -> {
handleCors(exchange);
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("[PlayerTime] Failed to get online players list", e);
sendResponse(exchange, 500, "Internal Server Error");
}
});
// 玩家计数
server.createContext("/api/player-count", exchange -> {
handleCors(exchange);
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("[PlayerTime] Failed to get player count", e);
sendResponse(exchange, 500, "Internal Server Error");
}
});
// 白名单玩家还有用吗
server.createContext("/api/whitelist", exchange -> {
handleCors(exchange);
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("[PlayerTime] Failed to get whitelist", e);
sendResponse(exchange, 500, "Internal Server Error");
}
});
// 服务器状态
server.createContext("/api/server-status", exchange -> {
handleCors(exchange);
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)); // formatUptime doesn't need localization
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("[PlayerTime] Failed to get server status", e);
sendResponse(exchange, 500, "Internal Server Error");
}
});
// 原始数据文件
server.createContext("/api/playerdata", exchange -> {
handleCors(exchange);
if ("OPTIONS".equals(exchange.getRequestMethod())) {
exchange.sendResponseHeaders(204, -1); // 204 No Content
return;
}
if (!"GET".equals(exchange.getRequestMethod())) {
sendResponse(exchange, 405, "Method Not Allowed");
return;
}
try {
Path dataFile = timeTracker.getDataFile(); // 从PlayerTimeTracker获取文件路径
if (!Files.exists(dataFile)) {
PlayerTimeMod.LOGGER.warn("[PlayerTime] Player data file not found: {}", dataFile);
sendResponse(exchange, 404, "Data file not found");
return;
}
byte[] fileContent = Files.readAllBytes(dataFile);
sendResponse(exchange, 200, fileContent, "application/json");
PlayerTimeMod.LOGGER.debug("[PlayerTime] Successfully served player data file {}", dataFile);
} catch (IOException e) {
PlayerTimeMod.LOGGER.error("[PlayerTime] Error reading player data file", e);
sendResponse(exchange, 500, "Error reading data file");
} catch (Exception e) {
PlayerTimeMod.LOGGER.error("[PlayerTime] An unknown error occurred while processing player data file request", e);
sendResponse(exchange, 500, "Internal Server Error"); sendResponse(exchange, 500, "Internal Server Error");
} }
}); });
@ -545,12 +390,14 @@ public class WebServer {
server.setExecutor(executor); server.setExecutor(executor);
} }
// 跨域处理
private void handleCors(HttpExchange exchange) { private void handleCors(HttpExchange exchange) {
exchange.getResponseHeaders().add("Access-Control-Allow-Origin", "*"); exchange.getResponseHeaders().add("Access-Control-Allow-Origin", "*");
exchange.getResponseHeaders().add("Access-Control-Allow-Methods", "GET, OPTIONS"); exchange.getResponseHeaders().add("Access-Control-Allow-Methods", "GET, OPTIONS");
exchange.getResponseHeaders().add("Access-Control-Allow-Headers", "Content-Type"); exchange.getResponseHeaders().add("Access-Control-Allow-Headers", "Content-Type");
} }
// 发送响应
private void sendResponse(HttpExchange exchange, int code, String response) throws IOException { private void sendResponse(HttpExchange exchange, int code, String response) throws IOException {
sendResponse(exchange, code, response.getBytes(StandardCharsets.UTF_8), "text/plain"); sendResponse(exchange, code, response.getBytes(StandardCharsets.UTF_8), "text/plain");
} }
@ -563,16 +410,26 @@ public class WebServer {
} }
} }
// 启动
public void start() { public void start() {
server.start(); server.start();
} }
// 停止
public void stop() { public void stop() {
server.stop(0); server.stop(3);
executor.shutdown(); executor.shutdown();
try {
if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
executor.shutdownNow(); // 强制关闭
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}
} }
// 格式化时间
private String formatUptime(long millis) { private String formatUptime(long millis) {
long seconds = millis / 1000; long seconds = millis / 1000;
long days = seconds / 86400; long days = seconds / 86400;
@ -582,6 +439,12 @@ public class WebServer {
long minutes = seconds / 60; long minutes = seconds / 60;
seconds %= 60; seconds %= 60;
return String.format("%d天 %02d小时 %02d分钟 %02d秒", days, hours, minutes, seconds); StringBuilder sb = new StringBuilder();
if (days > 0) sb.append(days).append("");
if (hours > 0 || days > 0) sb.append(String.format("%02d小时 ", hours));
if (minutes > 0 || hours > 0 || days > 0) sb.append(String.format("%02d分钟 ", minutes));
sb.append(String.format("%02d秒", seconds));
return sb.toString().trim();
} }
} }

View File

@ -0,0 +1,19 @@
package com.example.playertime.api;
import java.util.List;
import java.util.Map;
// 用于 /api/online-players 接口返回的在线玩家列表数据结构
public class OnlinePlayersResponse {
public List<Map<String, String>> whitelisted;
public List<Map<String, String>> non_whitelisted;
// Gson 需要一个无参构造函数
@SuppressWarnings("unused")
private OnlinePlayersResponse() {}
public OnlinePlayersResponse(List<Map<String, String>> whitelisted, List<Map<String, String>> non_whitelisted) {
this.whitelisted = whitelisted;
this.non_whitelisted = non_whitelisted;
}
}

View File

@ -0,0 +1,18 @@
package com.example.playertime.api;
// 用于 /api/player-count 接口返回的玩家数量数据结构
public class PlayerCountResponse {
public int total;
public int whitelisted;
public int non_whitelisted;
// Gson 需要一个无参构造函数
@SuppressWarnings("unused")
private PlayerCountResponse() {}
public PlayerCountResponse(int total, int whitelisted, int non_whitelisted) {
this.total = total;
this.whitelisted = whitelisted;
this.non_whitelisted = non_whitelisted;
}
}

View File

@ -0,0 +1,28 @@
package com.example.playertime.api;
// 用于 /api/stats 接口返回的玩家统计数据结构
public class PlayerStatsResponse {
public String playerName;
public String uuid;
public long totalTimeSeconds; // 总时长
public String totalTimeFormatted; // 总时长格式化字符串
public long last30DaysSeconds; // 30天时长
public String last30DaysFormatted; // 30天时长格式化字符串
public long last7DaysSeconds; // 7天时长
public String last7DaysFormatted; // 7天时长格式化字符串
// Gson 需要一个无参构造函数
@SuppressWarnings("unused")
private PlayerStatsResponse() {}
public PlayerStatsResponse(String playerName, String uuid, long totalTimeSeconds, String totalTimeFormatted, long last30DaysSeconds, String last30DaysFormatted, long last7DaysSeconds, String last7DaysFormatted) {
this.playerName = playerName;
this.uuid = uuid;
this.totalTimeSeconds = totalTimeSeconds;
this.totalTimeFormatted = totalTimeFormatted;
this.last30DaysSeconds = last30DaysSeconds;
this.last30DaysFormatted = last30DaysFormatted;
this.last7DaysSeconds = last7DaysSeconds;
this.last7DaysFormatted = last7DaysFormatted;
}
}

View File

@ -0,0 +1,37 @@
package com.example.playertime.api;
import java.util.List;
// 用于 /api/server-status 接口返回的服务器状态数据结构
public class ServerStatusResponse {
public MemoryStats memory = new MemoryStats();
public DiskStats disk = new DiskStats();
public int available_processors;
public long uptime; //
public String uptime_formatted;
public ServerInfo server = new ServerInfo();
public static class MemoryStats {
public long max; // 字节
public long total; // 字节
public long used; // 字节
public long free; // 字节
public double usage_percentage;
}
public static class DiskStats {
public long total; // 字节
public long free; // 字节
public long usable; // 字节
public double usage_percentage;
}
public static class ServerInfo {
public String version;
public String motd;
public int player_count;
public int max_players;
public double average_tick_time_ms; // 平均 MSPT
public List<Double> recent_tick_samples_ms; // 最近 Tick 样本 (毫秒)
}
}

View File

@ -0,0 +1,18 @@
package com.example.playertime.api;
// 用于 /api/whitelist 接口返回的白名单列表数据结构
public class WhitelistResponse {
public String name;
public String uuid;
public boolean online;
// Gson 需要一个无参构造函数
@SuppressWarnings("unused")
private WhitelistResponse() {}
public WhitelistResponse(String name, String uuid, boolean online) {
this.name = name;
this.uuid = uuid;
this.online = online;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -7,6 +7,9 @@
"playertime.stats.total": "Total Time", "playertime.stats.total": "Total Time",
"playertime.stats.30day": "Last 30 Days", "playertime.stats.30day": "Last 30 Days",
"playertime.stats.7day": "Last 7 Days", "playertime.stats.7day": "Last 7 Days",
"playertime.command.empty_stats": "No player online time data available.",
"playertime.command.error.not_initialized": "Player Time Mod is not fully initialized, please try again later.",
"playertime.command.error.player_only": "This command can only be executed by a player.",
"playertime.web.title": "Player online and status statistics", "playertime.web.title": "Player online and status statistics",
"playertime.web.warning": "Data tracking starts from the mod installation time and does not include any data before installation.", "playertime.web.warning": "Data tracking starts from the mod installation time and does not include any data before installation.",
@ -27,6 +30,17 @@
"playertime.web.server_status.memory_percent": "Usage", "playertime.web.server_status.memory_percent": "Usage",
"playertime.web.server_status.performance": "Real-time Performance", "playertime.web.server_status.performance": "Real-time Performance",
"playertime.web.server_status.uptime": "Server Uptime", "playertime.web.server_status.uptime": "Server Uptime",
"playertime.web.server_status.disk_usage": "Disk Usage",
"playertime.web.server_status.disk_used": "Used",
"playertime.web.server_status.disk_free": "Free",
"playertime.web.server_status.disk_percent": "Usage",
"playertime.web.server_status.processors": "Processors",
"playertime.web.server_status.server_version": "Server Version",
"playertime.web.server_status.player_count": "Online Players",
"playertime.web.server_status.disk_total": "Total",
"playertime.web.server_status.motd": "MOTD",
"playertime.web.server_status.not_available": "N/A",
"playertime.web.stats_table.title": "Player Online Time (Whitelist)", "playertime.web.stats_table.title": "Player Online Time (Whitelist)",
"playertime.web.stats_table.info": "Only tracks and displays whitelisted players.", "playertime.web.stats_table.info": "Only tracks and displays whitelisted players.",
@ -35,12 +49,17 @@
"playertime.web.stats_table.header.last_30_days": "Last 30 Days", "playertime.web.stats_table.header.last_30_days": "Last 30 Days",
"playertime.web.stats_table.header.last_7_days": "Last 7 Days", "playertime.web.stats_table.header.last_7_days": "Last 7 Days",
"playertime.web.stats_table.empty": "No player data available", "playertime.web.stats_table.empty": "No player data available",
"playertime.web.chart.memory_used": "Used", "playertime.web.chart.memory_used": "Used",
"playertime.web.chart.memory_free": "Free", "playertime.web.chart.memory_free": "Free",
"playertime.web.chart.tps": "TPS", "playertime.web.chart.tps": "TPS",
"playertime.web.chart.mspt": "MSPT", "playertime.web.chart.mspt": "MSPT",
"playertime.web.chart.disk_used": "Used",
"playertime.web.chart.disk_free": "Free",
"playertime.web.refresh_button": "Refresh", "playertime.web.refresh_button": "Refresh",
"playertime.web.error.load_failed": "Failed to load data, check console" "playertime.web.error.load_failed": "Failed to load data, check console",
"playertime.web.footer.license": "This project is open source under the GPL3 license"
} }

View File

@ -7,6 +7,10 @@
"playertime.stats.total": "总时长", "playertime.stats.total": "总时长",
"playertime.stats.30day": "30天", "playertime.stats.30day": "30天",
"playertime.stats.7day": "7天", "playertime.stats.7day": "7天",
"playertime.command.empty_stats": "暂无玩家在线时间数据。",
"playertime.command.error.not_initialized": "在线时间Mod未完全初始化请稍后再试。",
"playertime.command.error.player_only": "此命令只能由玩家执行。",
"playertime.web.title": "玩家在线及状态统计", "playertime.web.title": "玩家在线及状态统计",
"playertime.web.warning": "数据统计时间开始于此MOD安装时间不包含安装之前的所有数据", "playertime.web.warning": "数据统计时间开始于此MOD安装时间不包含安装之前的所有数据",
@ -27,6 +31,17 @@
"playertime.web.server_status.memory_percent": "使用率", "playertime.web.server_status.memory_percent": "使用率",
"playertime.web.server_status.performance": "实时性能", "playertime.web.server_status.performance": "实时性能",
"playertime.web.server_status.uptime": "服务器运行时间", "playertime.web.server_status.uptime": "服务器运行时间",
"playertime.web.server_status.disk_usage": "磁盘使用",
"playertime.web.server_status.disk_used": "已用",
"playertime.web.server_status.disk_free": "可用",
"playertime.web.server_status.disk_percent": "使用率",
"playertime.web.server_status.processors": "处理器核心",
"playertime.web.server_status.server_version": "服务器版本",
"playertime.web.server_status.player_count": "在线玩家",
"playertime.web.server_status.disk_total": "总量",
"playertime.web.server_status.motd": "MOTD",
"playertime.web.server_status.not_available": "N/A",
"playertime.web.stats_table.title": "玩家在线时长 (白名单)", "playertime.web.stats_table.title": "玩家在线时长 (白名单)",
"playertime.web.stats_table.info": "仅跟踪和显示列入白名单的玩家", "playertime.web.stats_table.info": "仅跟踪和显示列入白名单的玩家",
@ -35,12 +50,18 @@
"playertime.web.stats_table.header.last_30_days": "最近 30 天", "playertime.web.stats_table.header.last_30_days": "最近 30 天",
"playertime.web.stats_table.header.last_7_days": "最近 7 天", "playertime.web.stats_table.header.last_7_days": "最近 7 天",
"playertime.web.stats_table.empty": "暂无玩家数据", "playertime.web.stats_table.empty": "暂无玩家数据",
"playertime.web.chart.memory_used": "已使用", "playertime.web.chart.memory_used": "已使用",
"playertime.web.chart.memory_free": "未使用", "playertime.web.chart.memory_free": "未使用",
"playertime.web.chart.tps": "TPS", "playertime.web.chart.tps": "TPS",
"playertime.web.chart.mspt": "MSPT", "playertime.web.chart.mspt": "MSPT",
"playertime.web.chart.disk_used": "已用",
"playertime.web.chart.disk_free": "可用",
"playertime.web.refresh_button": "刷新数据", "playertime.web.refresh_button": "刷新数据",
"playertime.web.error.load_failed": "加载数据失败,请检查控制台" "playertime.web.error.load_failed": "加载数据失败,请检查控制台",
"playertime.web.footer.license": "本项目基于 GPL3许可证 开源"
} }

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@ -1,130 +1,263 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="zh_cn" data-theme="light">
<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, maximum-scale=1.0, user-scalable=no">
<title data-lang-key="playertime.web.title">玩家在线及状态统计</title> <title data-lang-key="playertime.web.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"> <link rel="icon" href="favicon.ico" type="image/x-icon">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
</head> </head>
<body> <body>
<div class="container"> <div class="app-container">
<div class="header"> <!-- 顶部导航栏 -->
<h1 data-lang-key="playertime.web.title">玩家在线时间统计</h1> <nav class="navbar">
<div class="navbar-content">
<button id="theme-toggle" class="theme-toggle" aria-label="切换主题"> <div class="navbar-left">
<i class="fas fa-moon"></i> <h1 class="app-title" data-lang-key="playertime.web.title">玩家在线时间统计</h1>
<i class="fas fa-sun"></i> </div>
</button> <div class="navbar-right">
</div> <button id="theme-toggle" class="theme-toggle" aria-label="切换主题">
<div class="warning" data-lang-key="playertime.web.warning">数据统计时间开始于此MOD安装时间不包含安装之前的所有数据</div> <i class="fas fa-moon theme-icon"></i>
<br> <i class="fas fa-sun theme-icon"></i>
</button>
<!-- 在线列表-->
<div class="status-cards">
<div class="status-card">
<h3 data-lang-key="playertime.web.status.online_players">在线玩家</h3>
<div class="player-counts">
<div class="count-item">
<span class="count-label" data-lang-key="playertime.web.status.total">总数:</span>
<span class="count-value" id="total-count">0</span>
</div>
<div class="count-item">
<span class="count-label" data-lang-key="playertime.web.status.players">玩家:</span>
<span class="count-value" id="whitelist-count">0</span>
</div>
<div class="count-item">
<span class="count-label" data-lang-key="playertime.web.status.non_players">假人:</span>
<span class="count-value" id="non-whitelist-count">0</span>
</div>
</div> </div>
</div> </div>
<div class="status-card"> </nav>
<h3 data-lang-key="playertime.web.status.online_list">在线玩家列表</h3>
<div class="player-lists"> <div class="main-content">
<div class="player-list"> <!-- 警告信息 -->
<h4 data-lang-key="playertime.web.status.online_list.players">玩家</h4> <div class="notification warning" data-lang-key="playertime.web.warning">
<ul class="online-players" id="whitelist-players"></ul> <i class="fas fa-exclamation-circle"></i>
</div> <span>数据统计时间开始于此MOD安装时间不包含安装之前的所有数据</span>
<div class="player-list">
<h4 data-lang-key="playertime.web.status.online_list.non_players">假人</h4>
<ul class="online-players" id="non-whitelist-players"></ul>
</div>
</div>
</div> </div>
</div>
<!-- 服务器状态--> <!-- 在线玩家卡片 -->
<div class="status-section"> <section class="dashboard-section">
<h2 data-lang-key="playertime.web.server_status.title">服务器状态</h2> <div class="section-header">
<div class="status-grid"> <h2>在线状态</h2>
<div class="status-item"> </div>
<h3 data-lang-key="playertime.web.server_status.memory_usage">内存使用</h3>
<canvas id="memory-chart"></canvas> <div class="stats-grid">
<div class="memory-stats"> <!-- 玩家计数卡片 -->
<div><span data-lang-key="playertime.web.server_status.memory_used">已用</span>: <span id="memory-used">0</span> MB</div> <div class="stat-card accent-blue">
<div><span data-lang-key="playertime.web.server_status.memory_free">可用</span>: <span id="memory-free">0</span> MB</div> <div class="stat-icon">
<div><span data-lang-key="playertime.web.server_status.memory_percent">使用率</span>: <span id="memory-percent">0</span>%</div> <i class="fas fa-users"></i>
</div>
<div class="stat-content">
<div class="stat-value" id="total-count">0</div>
<div class="stat-label" data-lang-key="playertime.web.status.total">总数</div>
</div>
</div>
<div class="stat-card accent-green">
<div class="stat-icon">
<i class="fas fa-user"></i>
</div>
<div class="stat-content">
<div class="stat-value" id="whitelist-count">0</div>
<div class="stat-label" data-lang-key="playertime.web.status.players">玩家</div>
</div>
</div>
<div class="stat-card accent-purple">
<div class="stat-icon">
<i class="fas fa-robot"></i>
</div>
<div class="stat-content">
<div class="stat-value" id="non-whitelist-count">0</div>
<div class="stat-label" data-lang-key="playertime.web.status.non_players">假人</div>
</div>
</div> </div>
</div> </div>
<div class="status-item"> <!-- 在线玩家列表 -->
<h3 data-lang-key="playertime.web.server_status.performance">实时性能</h3> <div class="player-lists-container">
<div class="chart-container"> <div class="player-list-card">
<div class="metric-display"> <div class="player-list-header">
<div class="metric"> <h3 data-lang-key="playertime.web.status.online_list.players">玩家</h3>
<span class="metric-label" data-lang-key="playertime.web.chart.tps">TPS:</span> <span class="player-count-badge" id="whitelist-count-badge">0</span>
</div>
<ul class="player-list" id="whitelist-players"></ul>
</div>
<div class="player-list-card">
<div class="player-list-header">
<h3 data-lang-key="playertime.web.status.online_list.non_players">假人</h3>
<span class="player-count-badge" id="non-whitelist-count-badge">0</span>
</div>
<ul class="player-list" id="non-whitelist-players"></ul>
</div>
</div>
</section>
<!-- 服务器状态 -->
<section class="dashboard-section">
<div class="section-header">
<h2 data-lang-key="playertime.web.server_status.title">服务器状态</h2>
<div class="uptime-display">
<i class="fas fa-clock"></i>
<span id="uptime">加载中...</span>
</div>
</div>
<div class="server-stats-grid">
<!-- 内存使用 -->
<div class="stat-card">
<div class="stat-header">
<i class="fas fa-memory"></i>
<h3 data-lang-key="playertime.web.server_status.memory_usage">内存使用</h3>
</div>
<div class="chart-wrapper">
<canvas id="memory-chart"></canvas>
</div>
<div class="memory-stats">
<div class="memory-stat">
<span data-lang-key="playertime.web.server_status.memory_used">已用</span>
<span id="memory-used">0</span> MB
</div>
<div class="memory-stat">
<span data-lang-key="playertime.web.server_status.memory_free">可用</span>
<span id="memory-free">0</span> MB
</div>
<div class="memory-stat">
<span data-lang-key="playertime.web.server_status.memory_percent">使用率</span>
<span id="memory-percent">0</span>%
</div>
</div>
</div>
<!-- 磁盘使用 -->
<div class="stat-card">
<div class="stat-header">
<i class="fas fa-hard-drive"></i>
<h3 data-lang-key="playertime.web.server_status.disk_usage">磁盘使用</h3>
</div>
<div class="chart-wrapper">
<canvas id="disk-chart"></canvas>
</div>
<div class="memory-stats">
<div class="memory-stat">
<span data-lang-key="playertime.web.server_status.disk_total">总量</span>
<span id="disk-total">0</span> GB
</div>
<div class="memory-stat">
<span data-lang-key="playertime.web.server_status.disk_used">已用</span>
<span id="disk-used">0</span> GB
</div>
<div class="memory-stat">
<span data-lang-key="playertime.web.server_status.disk_free">可用</span>
<span id="disk-free">0</span> GB
</div>
<div class="memory-stat">
<span data-lang-key="playertime.web.server_status.disk_percent">使用率</span>
<span id="disk-percent">0</span>%
</div>
</div>
</div>
<!-- 服务器信息 -->
<div class="stat-card">
<div class="stat-header">
<i class="fas fa-server"></i>
<h3 data-lang-key="playertime.web.server_status.server_info">服务器信息</h3>
</div>
<div class="server-info">
<div class="info-item">
<span data-lang-key="playertime.web.server_status.server_version">服务器版本</span>
<span id="server-version">未知</span>
</div>
<div class="info-item">
<span data-lang-key="playertime.web.server_status.processors">处理器核心</span>
<span id="processors">0</span>
</div>
<div class="info-item">
<span data-lang-key="playertime.web.server_status.player_count">在线玩家</span>
<span id="server-player-count">0</span>/<span id="server-max-players">0</span>
</div>
<div class="info-item">
<span data-lang-key="playertime.web.server_status.motd">MOTD</span>
<span id="server-motd">未知</span>
</div>
</div>
</div>
</div>
<!-- 实时性能卡片(单独一行) -->
<div class="performance-card">
<div class="stat-header">
<i class="fas fa-tachometer-alt"></i>
<h3 data-lang-key="playertime.web.server_status.performance">实时性能</h3>
</div>
<div class="performance-content">
<div class="performance-metrics">
<div class="metric-card">
<span class="metric-label" data-lang-key="playertime.web.chart.tps">TPS</span>
<span class="metric-value" id="tps-value">0.0</span> <span class="metric-value" id="tps-value">0.0</span>
</div> </div>
<div class="metric"> <div class="metric-card">
<span class="metric-label" data-lang-key="playertime.web.chart.mspt">MSPT:</span> <span class="metric-label" data-lang-key="playertime.web.chart.mspt">MSPT</span>
<span class="metric-value" id="mspt-value">0.0</span> <span class="metric-value" id="mspt-value">0.0</span>
</div> </div>
</div> </div>
<div class="chart-wrapper"> <div class="chart-container">
<canvas id="performance-chart"></canvas> <canvas id="performance-chart"></canvas>
</div> </div>
</div> </div>
</div> </div>
</section>
<!-- 玩家在线时长 -->
<section class="dashboard-section">
<div class="section-header">
<h2 data-lang-key="playertime.web.stats_table.title">玩家在线时长 (白名单)</h2>
<div class="section-actions">
<button id="refresh-btn" class="fluent-button primary">
<i class="fas fa-sync-alt"></i>
<span data-lang-key="playertime.web.refresh_button">刷新数据</span>
</button>
</div>
</div>
<div class="info-note">
<i class="fas fa-info-circle"></i>
<span data-lang-key="playertime.web.stats_table.info">仅跟踪和显示列入白名单的玩家</span>
</div>
<div class="table-container">
<table id="stats-table">
<thead>
<tr>
<th data-lang-key="playertime.web.stats_table.header.player">玩家</th>
<th data-lang-key="playertime.web.stats_table.header.total_time">总计时间</th>
<th data-lang-key="playertime.web.stats_table.header.last_30_days">最近 30 天</th>
<th data-lang-key="playertime.web.stats_table.header.last_7_days">最近 7 天</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
</div>
<footer class="footer">
<div class="footer-content">
<div class="license-info">
<p data-lang-key="playertime.web.footer.license">本项目基于 GPL3许可证 开源</p>
<p>Copyright © 2025 <a href="https://git.branulf.top/BRanulf" target="_blank">BRanulf</a></p>
</div>
</div> </div>
</div> </footer>
<div class="status-item full-width">
<h3 data-lang-key="playertime.web.server_status.uptime">服务器运行时间</h3>
<p id="uptime">加载中...</p>
</div>
<!-- 在线时长 -->
<h2 data-lang-key="playertime.web.stats_table.title">玩家在线时长 (白名单)</h2>
<div class="controls">
<button id="refresh-btn" class="refresh-btn">
<i class="fas fa-sync-alt"></i>
<span data-lang-key="playertime.web.refresh_button">刷新数据</span>
</button>
<p class="info-note" data-lang-key="playertime.web.stats_table.info">仅跟踪和显示列入白名单的玩家</p>
</div>
<div class="stats-container">
<table id="stats-table">
<thead>
<tr>
<th data-lang-key="playertime.web.stats_table.header.player">玩家</th>
<th data-lang-key="playertime.web.stats_table.header.total_time">总计时间</th>
<th data-lang-key="playertime.web.stats_table.header.last_30_days">最近 30 天</th>
<th data-lang-key="playertime.web.stats_table.header.last_7_days">最近 7 天</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div> </div>
<div class="floating-refresh-btn">
<button id="floating-refresh-btn" class="fluent-button primary floating" title="刷新数据">
<i class="fas fa-sync-alt"></i>
</button>
</div>
<script src="js/app.js"></script> <script src="js/app.js"></script>
<footer class="license-footer">
<div class="license-info">
<p>本项目基于 GPL3许可证 开源</p>
<p>Copyright © 2025 <a href="https://git.branulf.top/BRanulf" target="_blank">BRanulf</a></p>
</div>
</footer>
</body> </body>
</html> </html>

View File

@ -1,12 +1,14 @@
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// 初始化变量 // 初始化变量
const tpsHistory = Array(30).fill(20); const tpsHistory = Array(60).fill(20);
const msptHistory = Array(30).fill(50); const msptHistory = Array(60).fill(0);
const performanceLabels = Array(60).fill('');
let memoryChart = null; let memoryChart = null;
let performanceChart = null; let performanceChart = null;
let diskChart = null;
let lang = {}; let lang = {};
// 获取语言 // DOM元素
const elements = { const elements = {
refreshBtn: document.getElementById('refresh-btn'), refreshBtn: document.getElementById('refresh-btn'),
themeToggle: document.getElementById('theme-toggle'), themeToggle: document.getElementById('theme-toggle'),
@ -20,25 +22,36 @@ document.addEventListener('DOMContentLoaded', function() {
memoryUsed: document.getElementById('memory-used'), memoryUsed: document.getElementById('memory-used'),
memoryFree: document.getElementById('memory-free'), memoryFree: document.getElementById('memory-free'),
memoryPercent: document.getElementById('memory-percent'), memoryPercent: document.getElementById('memory-percent'),
avgTick: document.getElementById('avg-tick'), diskTotal: document.getElementById('disk-total'),
diskUsed: document.getElementById('disk-used'),
diskFree: document.getElementById('disk-free'),
diskPercent: document.getElementById('disk-percent'),
processors: document.getElementById('processors'),
serverVersion: document.getElementById('server-version'),
serverPlayerCount: document.getElementById('server-player-count'),
serverMaxPlayers: document.getElementById('server-max-players'),
serverMotd: document.getElementById('server-motd'),
uptime: document.getElementById('uptime'), uptime: document.getElementById('uptime'),
tpsValue: document.getElementById('tps-value'), tpsValue: document.getElementById('tps-value'),
msptValue: document.getElementById('mspt-value') msptValue: document.getElementById('mspt-value'),
floatingRefreshBtn: document.getElementById('floating-refresh-btn')
}; };
// 加载保存的主题
const savedTheme = localStorage.getItem('theme') || 'light'; const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', savedTheme); document.documentElement.setAttribute('data-theme', savedTheme);
// 加载语言 // 初始设置:加载语言,初始化图表,加载数据,设置刷新间隔
loadLanguage().then(() => { loadLanguage().then(() => {
translatePage(); translatePage();
initCharts(); initCharts();
loadAllData(); loadAllData();
// 定时刷新 // 设置定期刷新每10秒一次后续加入配置文件
const refreshInterval = setInterval(loadAllData, 10000); const refreshInterval = setInterval(loadAllData, 10000);
console.log('数据刷新间隔设置为10秒。');
}).catch(error => { }).catch(error => {
console.error('Failed to load language or initial data:', error); console.error('加载语言或初始数据失败:', error);
showError('Failed to load language or initial data.'); showError('加载语言或初始数据失败。请检查控制台。');
}); });
if (elements.themeToggle) { if (elements.themeToggle) {
@ -49,18 +62,24 @@ document.addEventListener('DOMContentLoaded', function() {
elements.refreshBtn.addEventListener('click', handleRefresh); elements.refreshBtn.addEventListener('click', handleRefresh);
} }
if (elements.floatingRefreshBtn) {
elements.floatingRefreshBtn.addEventListener('click', handleRefresh);
}
// 后端加载语言
async function loadLanguage() { async function loadLanguage() {
try { try {
const response = await fetch('/api/lang'); const response = await fetch('/api/lang');
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); if (!response.ok) {
const errorText = await response.text();
console.error(`获取/api/lang时HTTP错误! 状态: ${response.status}, 内容: ${errorText}`);
throw new Error(`HTTP错误! 状态: ${response.status}`);
}
lang = await response.json(); lang = await response.json();
console.log('Language file loaded.'); console.log('语言文件加载成功。');
} catch (error) { } catch (error) {
console.error('Failed to load language file:', error); console.error('无法从/api/lang加载语言文件:', error);
lang = {}; lang = {};
throw error;
} }
} }
@ -74,11 +93,27 @@ document.addEventListener('DOMContentLoaded', function() {
element.textContent = lang[key]; element.textContent = lang[key];
} }
} else { } else {
console.warn(`Missing translation key: ${key}`);
} }
}); });
// 加载语言后更新图表标签
if (memoryChart) {
memoryChart.data.labels = [getLangString('playertime.web.chart.memory_used'), getLangString('playertime.web.chart.memory_free')];
memoryChart.update();
}
if (performanceChart) {
performanceChart.data.datasets[0].label = getLangString('playertime.web.chart.tps');
performanceChart.data.datasets[1].label = getLangString('playertime.web.chart.mspt');
if (performanceChart.options.scales.y.title) performanceChart.options.scales.y.title.text = getLangString('playertime.web.chart.tps');
if (performanceChart.options.scales.y1.title) performanceChart.options.scales.y1.title.text = getLangString('playertime.web.chart.mspt');
performanceChart.update();
}
if (diskChart) {
diskChart.data.labels = [getLangString('playertime.web.chart.disk_used'), getLangString('playertime.web.chart.disk_free')];
diskChart.update();
}
} }
// 获取语言字符串
function getLangString(key, ...args) { function getLangString(key, ...args) {
const pattern = lang[key] || key; const pattern = lang[key] || key;
try { try {
@ -88,141 +123,13 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
return result; return result;
} catch (e) { } catch (e) {
console.error(`Failed to format string for key: ${key}`, e); console.error(`格式化键的字符串失败: ${key}`, e);
return pattern; return pattern;
} }
} }
// 图表 // 切换主题
function initCharts() {
const memoryCtx = document.getElementById('memory-chart')?.getContext('2d');
if (memoryCtx) {
memoryChart = new Chart(memoryCtx, {
type: 'doughnut',
data: {
labels: [getLangString('playertime.web.chart.memory_used'), getLangString('playertime.web.chart.memory_free')],
datasets: [{
data: [0, 100],
backgroundColor: ['#4361ee', '#e9ecef']
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
}
}
}
});
}
const perfCtx = document.getElementById('performance-chart')?.getContext('2d');
if (perfCtx) {
performanceChart = new Chart(perfCtx, {
type: 'line',
data: {
labels: Array(30).fill(''),
datasets: [
{
label: getLangString('playertime.web.chart.tps'),
data: tpsHistory,
borderColor: '#4CAF50',
backgroundColor: 'rgba(76, 175, 80, 0.1)',
borderWidth: 2,
pointRadius: 3,
pointHoverRadius: 5,
tension: 0.3,
yAxisID: 'y'
},
{
label: getLangString('playertime.web.chart.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: getLangString('playertime.web.chart.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: getLangString('playertime.web.chart.mspt'),
font: {
weight: 'bold'
}
},
min: 0,
grid: {
drawOnChartArea: false
}
}
}
}
});
}
}
function toggleTheme() { 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';
@ -230,8 +137,11 @@ document.addEventListener('DOMContentLoaded', function() {
localStorage.setItem('theme', newTheme); localStorage.setItem('theme', newTheme);
} }
// 防呆
function handleRefresh() { function handleRefresh() {
if (this.classList.contains('loading')) return; if (this.classList.contains('loading')) return;
this.classList.add('loading'); this.classList.add('loading');
loadAllData().finally(() => { loadAllData().finally(() => {
setTimeout(() => { setTimeout(() => {
@ -240,6 +150,7 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
} }
async function loadAllData() { async function loadAllData() {
try { try {
const [statsData, onlinePlayersData, playerCountsData, serverStatusData] = await Promise.all([ const [statsData, onlinePlayersData, playerCountsData, serverStatusData] = await Promise.all([
@ -263,8 +174,8 @@ document.addEventListener('DOMContentLoaded', function() {
async function fetchData(url) { async function fetchData(url) {
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
const errorBody = await response.text().catch(() => 'Unknown Error'); const errorBody = await response.text().catch(() => '未知错误');
throw new Error(`HTTP error! status: ${response.status}, URL: ${url}, Body: ${errorBody}`); throw new Error(`HTTP错误! 状态: ${response.status}, URL: ${url}, 内容: ${errorBody}`);
} }
return response.json(); return response.json();
} }
@ -273,6 +184,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (!elements.statsTableBody || !elements.statsTableHeader) return; if (!elements.statsTableBody || !elements.statsTableHeader) return;
elements.statsTableBody.innerHTML = ''; elements.statsTableBody.innerHTML = '';
const headers = elements.statsTableHeader.querySelectorAll('th[data-lang-key]'); const headers = elements.statsTableHeader.querySelectorAll('th[data-lang-key]');
headers.forEach(th => { headers.forEach(th => {
const key = th.getAttribute('data-lang-key'); const key = th.getAttribute('data-lang-key');
@ -281,41 +193,7 @@ document.addEventListener('DOMContentLoaded', function() {
} }
}); });
if (!Array.isArray(data) || data.length === 0) {
const sortedPlayers = Object.entries(data)
.map(([name, statString]) => {
const stats = {};
statString.split(" | ").forEach(part => {
const firstColonIndex = part.indexOf(':');
if (firstColonIndex > 0) {
const label = part.substring(0, firstColonIndex).trim();
const value = part.substring(firstColonIndex + 1).trim();
stats[label] = value;
} else {
console.warn(`Could not parse stat part: ${part}`);
}
});
const internalStats = {};
const formatParts = getLangString('playertime.stats.format').split(" | ").map(p => p.split(":")[0].trim());
if (formatParts.length >= 3) {
internalStats.totalTime = stats[formatParts[0]];
internalStats['30Days'] = stats[formatParts[1]];
internalStats['7Days'] = stats[formatParts[2]];
} else {
console.error("Unexpected format string structure:", getLangString('playertime.stats.format'));
internalStats.totalTime = statString.split(" | ")[0]?.split(": ")[1] || "0h 00m";
internalStats['30Days'] = statString.split(" | ")[1]?.split(": ")[1] || "0h 00m";
internalStats['7Days'] = statString.split(" | ")[2]?.split(": ")[1] || "0h 00m";
}
return { name, stats: internalStats };
})
.sort((a, b) => parseTimeToSeconds(b.stats.totalTime) - parseTimeToSeconds(a.stats.totalTime));
if (sortedPlayers.length === 0) {
const row = elements.statsTableBody.insertRow(); const row = elements.statsTableBody.insertRow();
const cell = row.insertCell(0); const cell = row.insertCell(0);
cell.colSpan = 4; cell.colSpan = 4;
@ -325,16 +203,16 @@ document.addEventListener('DOMContentLoaded', function() {
return; return;
} }
data.forEach(player => {
sortedPlayers.forEach(player => {
const row = elements.statsTableBody.insertRow(); const row = elements.statsTableBody.insertRow();
row.insertCell(0).textContent = player.name; row.insertCell(0).textContent = player.playerName;
row.insertCell(1).textContent = player.stats.totalTime; row.insertCell(1).textContent = player.totalTimeFormatted;
row.insertCell(2).textContent = player.stats['30Days']; row.insertCell(2).textContent = player.last30DaysFormatted;
row.insertCell(3).textContent = player.stats['7Days']; row.insertCell(3).textContent = player.last7DaysFormatted;
}); });
} }
// 更新在线玩家列表
function updateOnlinePlayers(data) { function updateOnlinePlayers(data) {
if (!elements.whitelistPlayers || !elements.nonWhitelistPlayers) return; if (!elements.whitelistPlayers || !elements.nonWhitelistPlayers) return;
@ -355,16 +233,21 @@ document.addEventListener('DOMContentLoaded', function() {
updateList(elements.nonWhitelistPlayers, data.non_whitelisted, 'playertime.web.status.online_list.empty_non_players'); updateList(elements.nonWhitelistPlayers, data.non_whitelisted, 'playertime.web.status.online_list.empty_non_players');
} }
// 更新在线玩家计数
function updatePlayerCounts(data) { function updatePlayerCounts(data) {
if (elements.totalCount) elements.totalCount.textContent = data.total || 0; if (elements.totalCount) elements.totalCount.textContent = data.total || 0;
if (elements.whitelistCount) elements.whitelistCount.textContent = data.whitelisted || 0; if (elements.whitelistCount) elements.whitelistCount.textContent = data.whitelisted || 0;
if (elements.nonWhitelistCount) elements.nonWhitelistCount.textContent = data.non_whitelisted || 0; if (elements.nonWhitelistCount) elements.nonWhitelistCount.textContent = data.non_whitelisted || 0;
document.getElementById('whitelist-count-badge').textContent = data.whitelisted || 0;
document.getElementById('non-whitelist-count-badge').textContent = data.non_whitelisted || 0;
} }
// 更新服务器状态信息和图表
function updateServerStatus(data) { function updateServerStatus(data) {
if (data.memory) { if (data.memory) {
const usedMB = Math.round(data.memory.used / (1024 * 1024)); const usedMB = Math.round(data.memory.used / (1024 * 1024));
const freeMB = Math.round((data.memory.max - data.memory.used) / (1024 * 1024)); const freeMB = Math.round(data.memory.free / (1024 * 1024));
const percent = Math.round(data.memory.usage_percentage); const percent = Math.round(data.memory.usage_percentage);
if (memoryChart) { if (memoryChart) {
@ -378,56 +261,286 @@ document.addEventListener('DOMContentLoaded', function() {
if (elements.memoryPercent) elements.memoryPercent.textContent = percent; if (elements.memoryPercent) elements.memoryPercent.textContent = percent;
} }
// 更新性能统计和图表TPS/MSPT
if (data.server) { if (data.server) {
const tps = Math.min(20, 1000 / (data.server.average_tick_time_ms || 50)).toFixed(1); const mspt = data.server.average_tick_time_ms || 0;
const mspt = data.server.average_tick_time_ms.toFixed(1); const tps = Math.min(20, 1000 / (mspt || 1)).toFixed(1);
if (elements.tpsValue) elements.tpsValue.textContent = tps; if (elements.tpsValue) elements.tpsValue.textContent = tps;
if (elements.msptValue) elements.msptValue.textContent = mspt; if (elements.msptValue) elements.msptValue.textContent = mspt.toFixed(1);
if (performanceChart) { if (performanceChart) {
tpsHistory.shift(); tpsHistory.shift();
tpsHistory.push(parseFloat(tps)); tpsHistory.push(parseFloat(tps));
msptHistory.shift(); msptHistory.shift();
msptHistory.push(parseFloat(mspt)); msptHistory.push(mspt);
performanceChart.data.datasets[0].label = getLangString('playertime.web.chart.tps');
performanceChart.data.datasets[1].label = getLangString('playertime.web.chart.mspt');
if (performanceChart.options.scales.y.title) performanceChart.options.scales.y.title.text = getLangString('playertime.web.chart.tps');
if (performanceChart.options.scales.y1.title) performanceChart.options.scales.y1.title.text = getLangString('playertime.web.chart.mspt');
performanceChart.data.datasets[0].data = tpsHistory; performanceChart.data.datasets[0].data = tpsHistory;
performanceChart.data.datasets[1].data = msptHistory; performanceChart.data.datasets[1].data = msptHistory;
performanceChart.update('none'); performanceChart.update('none');
setMetricColor(elements.tpsValue, tps, 15, 10, false); setMetricColor(elements.tpsValue, parseFloat(tps), 18, 15, false);
setMetricColor(elements.msptValue, mspt, 50, 100, true); setMetricColor(elements.msptValue, mspt, 50, 60, true);
}
}
if (elements.uptime) elements.uptime.textContent = data.uptime_formatted || '0';
}
function parseTimeToSeconds(timeStr) {
if (!timeStr) return 0;
const parts = timeStr.match(/(\d+h)?\s*(\d+m)?/);
let seconds = 0;
if (parts) {
if (parts[1]) { // hour
seconds += parseInt(parts[1].replace('h', '')) * 3600;
}
if (parts[2]) { // min
seconds += parseInt(parts[2].replace('m', '')) * 60;
} }
} }
return seconds;
// 更新磁盘统计和图表
if (data.disk && data.disk.total > 0) {
const totalGB = Math.round(data.disk.total / (1024 * 1024 * 1024));
const usedGB = Math.round((data.disk.total - data.disk.free) / (1024 * 1024 * 1024));
const freeGB = Math.round(data.disk.free / (1024 * 1024 * 1024));
const percent = Math.round(data.disk.usage_percentage);
if (diskChart) {
diskChart.data.datasets[0].data = [usedGB, freeGB];
diskChart.data.labels = [getLangString('playertime.web.chart.disk_used'), getLangString('playertime.web.chart.disk_free')];
diskChart.update();
}
if (elements.diskTotal) elements.diskTotal.textContent = totalGB;
if (elements.diskUsed) elements.diskUsed.textContent = usedGB;
if (elements.diskFree) elements.diskFree.textContent = freeGB;
if (elements.diskPercent) elements.diskPercent.textContent = percent;
} else if (data.disk && data.disk.total === 0) {
if (elements.diskTotal) elements.diskTotal.textContent = getLangString('playertime.web.server_status.not_available') || 'N/A';
if (elements.diskUsed) elements.diskUsed.textContent = getLangString('playertime.web.server_status.not_available') || 'N/A';
if (elements.diskFree) elements.diskFree.textContent = getLangString('playertime.web.server_status.not_available') || 'N/A';
if (elements.diskPercent) elements.diskPercent.textContent = getLangString('playertime.web.server_status.not_available') || 'N/A';
if (diskChart) {
diskChart.data.datasets[0].data = [1, 0];
diskChart.data.labels = [getLangString('playertime.web.server_status.not_available') || 'N/A', ''];
diskChart.update();
}
}
// 更新服务器信息
if (elements.processors) elements.processors.textContent = data.available_processors || 0;
if (data.server) {
if (elements.serverVersion) elements.serverVersion.textContent = data.server.version || '未知';
if (elements.serverPlayerCount) elements.serverPlayerCount.textContent = data.server.player_count || 0;
if (elements.serverMaxPlayers) elements.serverMaxPlayers.textContent = data.server.max_players || 0;
if (elements.serverMotd) elements.serverMotd.textContent = data.server.motd || '未知';
}
// 更新运行时间
if (elements.uptime) elements.uptime.textContent = data.uptime_formatted || '加载中...';
} }
function initCharts() {
// 内存图表
const memoryCtx = document.getElementById('memory-chart')?.getContext('2d');
if (memoryCtx) {
memoryChart = new Chart(memoryCtx, {
type: 'doughnut',
data: {
labels: [getLangString('playertime.web.chart.memory_used'), getLangString('playertime.web.chart.memory_free')],
datasets: [{
data: [0, 100],
backgroundColor: ['#4361ee', '#e9ecef'],
borderColor: [
getComputedStyle(document.documentElement).getPropertyValue('--card-bg').trim(),
getComputedStyle(document.documentElement).getPropertyValue('--card-bg').trim()
],
borderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
labels: {
color: getComputedStyle(document.documentElement).getPropertyValue('--text-color').trim()
}
},
tooltip: {
callbacks: {
label: function(context) {
const label = context.label || '';
const value = context.raw;
return `${label}: ${value} MB`;
}
}
}
}
}
});
}
// 性能图表TPS/MSPT
const perfCtx = document.getElementById('performance-chart')?.getContext('2d');
if (perfCtx) {
performanceChart = new Chart(perfCtx, {
type: 'line',
data: {
labels: performanceLabels,
datasets: [
{
label: getLangString('playertime.web.chart.tps'),
data: tpsHistory,
borderColor: '#4CAF50',
backgroundColor: 'rgba(76, 175, 80, 0.1)',
borderWidth: 2,
pointRadius: 2,
pointHoverRadius: 4,
tension: 0.3,
yAxisID: 'y'
},
{
label: getLangString('playertime.web.chart.mspt'),
data: msptHistory,
borderColor: '#FF5722',
backgroundColor: 'rgba(255, 87, 34, 0.1)',
borderWidth: 2,
pointRadius: 2,
pointHoverRadius: 4,
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
},
color: getComputedStyle(document.documentElement).getPropertyValue('--text-color').trim()
}
},
tooltip: {
mode: 'index',
intersect: false,
bodySpacing: 8,
callbacks: {
label: function(context) {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.dataset.yAxisID === 'y') {
label += context.raw.toFixed(1);
} else {
label += context.raw.toFixed(1) + ' ms';
}
return label;
}
}
}
},
scales: {
x: {
grid: {
display: false,
color: getComputedStyle(document.documentElement).getPropertyValue('--border-color').trim()
},
ticks: {
display: false
}
},
y: {
type: 'linear',
display: true,
position: 'left',
title: {
display: true,
text: getLangString('playertime.web.chart.tps'),
font: {
weight: 'bold',
color: getComputedStyle(document.documentElement).getPropertyValue('--text-color').trim()
},
color: getComputedStyle(document.documentElement).getPropertyValue('--text-color').trim()
},
min: 0,
max: 20,
grid: {
color: getComputedStyle(document.documentElement).getPropertyValue('--border-color').trim()
},
ticks: {
color: getComputedStyle(document.documentElement).getPropertyValue('--text-color').trim()
}
},
y1: {
type: 'linear',
display: true,
position: 'right',
title: {
display: true,
text: getLangString('playertime.web.chart.mspt'),
font: {
weight: 'bold',
color: getComputedStyle(document.documentElement).getPropertyValue('--text-color').trim()
},
color: getComputedStyle(document.documentElement).getPropertyValue('--text-color').trim()
},
min: 0,
suggestedMax: 100,
grid: {
drawOnChartArea: false,
color: getComputedStyle(document.documentElement).getPropertyValue('--border-color').trim()
},
ticks: {
color: getComputedStyle(document.documentElement).getPropertyValue('--text-color').trim()
}
}
}
}
});
}
// 磁盘图表
const diskCtx = document.getElementById('disk-chart')?.getContext('2d');
if (diskCtx) {
diskChart = new Chart(diskCtx, {
type: 'doughnut',
data: {
labels: [getLangString('playertime.web.chart.disk_used'), getLangString('playertime.web.chart.disk_free')],
datasets: [{
data: [0, 100],
backgroundColor: ['#4361ee', '#e9ecef'],
borderColor: [
getComputedStyle(document.documentElement).getPropertyValue('--card-bg').trim(),
getComputedStyle(document.documentElement).getPropertyValue('--card-bg').trim()
],
borderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
labels: {
color: getComputedStyle(document.documentElement).getPropertyValue('--text-color').trim()
}
},
tooltip: {
callbacks: {
label: function(context) {
const label = context.label || '';
const value = context.raw;
return `${label}: ${value} GB`;
}
}
}
}
}
});
}
}
function showError(message) { function showError(message) {
const errorEl = document.createElement('div'); const errorEl = document.createElement('div');
errorEl.className = 'error-message'; errorEl.className = 'error-message';
@ -449,24 +562,28 @@ document.addEventListener('DOMContentLoaded', function() {
const numValue = parseFloat(value); const numValue = parseFloat(value);
let color = ''; let color = '';
if (reverse) { const successColor = getComputedStyle(document.documentElement).getPropertyValue('--success-color').trim();
const warningColor = getComputedStyle(document.documentElement).getPropertyValue('--warning-color').trim();
const dangerColor = getComputedStyle(document.documentElement).getPropertyValue('--danger-color').trim();
if (reverse) { // 值越低越好例如MSPT
if (numValue <= goodThreshold) { if (numValue <= goodThreshold) {
color = '#4CAF50'; // Green color = successColor;
} else if (numValue <= badThreshold) { } else if (numValue <= badThreshold) {
color = '#FFC107'; // Yellow color = warningColor;
} else { } else {
color = '#FF5722'; // Red color = dangerColor;
} }
} else { } else { // 值越高越好例如TPS
if (numValue >= goodThreshold) { if (numValue >= goodThreshold) {
color = '#4CAF50'; // Green color = successColor;
} else if (numValue >= badThreshold) { } else if (numValue >= badThreshold) {
color = '#FFC107'; // Yellow color = warningColor;
} else { } else {
color = '#FF5722'; // Red color = dangerColor;
} }
} }
element.style.color = color; element.style.color = color;
} }
}); });

View File

@ -11,6 +11,7 @@
"homepage": "https://git.branulf.top/Branulf", "homepage": "https://git.branulf.top/Branulf",
"sources": "https://git.branulf.top/Branulf/ServerPlayerOnlineTracker" "sources": "https://git.branulf.top/Branulf/ServerPlayerOnlineTracker"
}, },
"icon": "assets/playertime/icon540.png",
"license": "GPL3", "license": "GPL3",
"environment": "server", "environment": "server",
"entrypoints": { "entrypoints": {

View File

@ -0,0 +1 @@
public field net/minecraft/server/MinecraftServer workerExecutor Ljava/util/concurrent/Executor;