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
[English](https://git.branulf.top/Branulf/ServerPlayerOnlineTracker/src/branch/master/README_EN.md)
注意本mod并不完善可能会存在些许的bug
### 包含功能:
* 显示/记录服务器在线玩家时间
* 显示服务器状态
@ -12,6 +13,7 @@
2. 将最新版本的mod放入mods文件夹
3. 首次启动服务器自动生成配置文件
4. 修改Web服务器端口(默认60048),语言(目前仅支持zh_cn和en_us),自动保存时间(默认300秒)
5. 如需在外部访问请确保端口已开放
### 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)
### Includes functions:
* Display /Record server online player time
* Display server status
* Show the server online player list
* ~~Remove Herobrine in the game~~
Note: This mod is not perfect and may have some bugs.
### Features include:
* Display/record server online player time
* Show server status
* Display a list of online players on the server
* ~~Remove Herobrine from the game~~
### How to use:
1. Install Fabric Loader and Fabric API
2. Put the latest version of mods into the mods folder
3. Automatically generate configuration files 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)
1. Install Fabric Loader and Fabric API.
2. Place the latest version of the mod into the mods folder.
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), and automatic save interval (default 300 seconds).
5. If external access is needed, ensure that the port is open.
### API to get data:
*(Maybe it can be used for robots?)*
* Get the online player list of servers: [Server IP+configured port] +`/api/online-players`
### API data retrieval:
*(Maybe can be used for bots?)*
* 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 the server online player time: [Server IP+configured port] +`/api/stats`
* Get the number of online players on the server: [Server IP+configured port] +`/api/player-count`
* Get online player time on the server: [Server IP + configured port] + `/api/stats`
* Get number of online players on the server: [Server IP + configured port] + `/api/player-count`
* ~~Raw data file: [Server IP + configured port] + `/api/playerdata`~~

View File

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

View File

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

View File

@ -12,39 +12,45 @@ import java.text.MessageFormat;
import java.util.HashMap;
import java.util.Map;
// 这个是gemini写的
// 这个是gemini写的 (保留原注释但代码已根据需求调整)
public class LocalizationManager {
private final Map<String, String> translations = new HashMap<>();
private final String defaultLanguage = "zh_cn";
private final String defaultLanguage = "zh_cn"; // 默认语言代码
public LocalizationManager(String languageCode) {
loadLanguage(languageCode);
if (!languageCode.equals(defaultLanguage)) {
// 尝试加载用户配置的语言
loadLanguage(languageCode, false);
// 如果配置的语言不是默认语言加载默认语言作为备用
if (!languageCode.equalsIgnoreCase(defaultLanguage)) {
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) {
String resourcePath = String.format("assets/playertime/lang/%s.json", languageCode);
try (InputStream is = FabricLoader.getInstance().getModContainer("playertime")
String resourcePath = String.format("assets/playertime/lang/%s.json", languageCode.toLowerCase()); // 确保小写
try {
// 使用 FabricLoader 获取 Mod 资源
InputStream is = FabricLoader.getInstance().getModContainer("playertime")
.flatMap(container -> container.findPath(resourcePath))
.map(path -> {
try {
return path.toUri().toURL().openStream();
} catch (Exception e) {
// 查找路径或打开流失败
return null;
}
})
.orElse(null)) {
.orElse(null); // 如果找不到路径或打开失败返回 null
if (is == null) {
if (!isFallback) {
PlayerTimeMod.LOGGER.warn("[PlayerTime] Language file not found for code: {}", languageCode);
PlayerTimeMod.LOGGER.warn("[PlayerTime] 未找到语言文件: {}", resourcePath);
}
return;
}
@ -52,33 +58,51 @@ public class LocalizationManager {
try (InputStreamReader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) {
JsonObject json = JsonParser.parseReader(reader).getAsJsonObject();
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());
} else if (!isFallback) {
// If loading primary, overwrite fallback
// 如果是加载备用语言只有当主语言没有该键时才添加
// 如果是加载主语言则覆盖备用语言中的同名键
if (!translations.containsKey(entry.getKey()) || !isFallback) {
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) {
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) {
return translations.getOrDefault(key, key);
}
/**
* 根据键获取本地化字符串并使用参数格式化如果找不到键返回原始键
* @param key 本地化键
* @param args 格式化参数
* @return 格式化后的本地化字符串或原始键
*/
public String getString(String key, Object... args) {
String pattern = getString(key);
try {
// MessageFormat 可以处理 {0}, {1} 等占位符
return MessageFormat.format(pattern, args);
} catch (IllegalArgumentException e) {
PlayerTimeMod.LOGGER.error("[PlayerTime] Failed to format localization string for key: {}", key, e);
return pattern;
PlayerTimeMod.LOGGER.error("[PlayerTime] 格式化本地化字符串失败 (键: {})", key, e);
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 {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private final Path configPath;
// 配置项
private int webPort = 60048;
private String language = "zh_cn";
private long autoSaveSeconds = 300;
private boolean whitelistOnly = true; // 默认只记录白名单玩家
private int saveIntervalMinutes = 5; // 默认每5分钟保存一次
public ModConfig(Path configDir) {
this.configPath = configDir.resolve("playertime-config.json");
loadConfig();
}
// 加载配置
private void loadConfig() {
if (!Files.exists(configPath)) {
PlayerTimeMod.LOGGER.info("[在线时间] 配置文件未找到,正在创建默认配置");
saveConfig();
PlayerTimeMod.LOGGER.info("[在线时间] 配置文件未找到,正在创建默认配置: {}", configPath);
saveConfig(); // 创建默认配置并保存
return;
}
@ -27,35 +31,68 @@ public class ModConfig {
JsonElement jsonElement = JsonParser.parseReader(reader);
if (jsonElement == null || !jsonElement.isJsonObject()) {
PlayerTimeMod.LOGGER.warn("[在线时间] 配置文件为空或格式错误,正在使用默认配置并覆盖");
saveConfig();
PlayerTimeMod.LOGGER.warn("[在线时间] 配置文件为空或格式错误,正在使用默认配置并覆盖: {}", configPath);
saveConfig(); // 使用默认配置并覆盖
return;
}
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();
} else {
PlayerTimeMod.LOGGER.info("[在线时间] 配置文件缺少“webPort”字段添加默认值'%s'并保存", webPort);
saveConfig();
if (webPort < 1 || webPort > 65535) {
PlayerTimeMod.LOGGER.warn("[在线时间] 配置文件中的 webPort ({}) 无效,使用默认值 {}", webPort, 60048);
webPort = 60048;
}
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();
PlayerTimeMod.LOGGER.info("[在线时间] 配置文件缺少或 webPort 格式错误,使用默认值 {}", webPort);
// 不立即保存等待所有字段读取完毕再决定是否保存
}
// 读取 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) {
PlayerTimeMod.LOGGER.error("[在线时间] 加载配置文件失败,使用默认配置", e);
saveConfig();
PlayerTimeMod.LOGGER.error("[在线时间] 加载配置文件 {} 时发生未知错误,使用默认配置", configPath, e);
saveConfig(); // 发生未知错误尝试保存默认配置
}
}
@ -64,16 +101,17 @@ public class ModConfig {
JsonObject json = new JsonObject();
json.addProperty("webPort", webPort);
json.addProperty("language", language);
json.addProperty("autoSaveSeconds", autoSaveSeconds);
json.addProperty("whitelistOnly", whitelistOnly);
json.addProperty("saveIntervalMinutes", saveIntervalMinutes);
try {
Files.createDirectories(configPath.getParent());
try (Writer writer = Files.newBufferedWriter(configPath)) {
GSON.toJson(json, writer);
}
PlayerTimeMod.LOGGER.info("[在线时间] 配置已成功保存");
PlayerTimeMod.LOGGER.info("[在线时间] 配置已成功保存到 {}", configPath);
} catch (Exception e) {
PlayerTimeMod.LOGGER.error("[在线时间] 保存配置失败", e);
PlayerTimeMod.LOGGER.error("[在线时间] 保存配置失败到 {}", configPath, e);
}
}
@ -87,8 +125,13 @@ public class ModConfig {
return language;
}
// 获取保存间隔
public long getSeconds() {
return autoSaveSeconds;
// 获取是否只记录白名单
public boolean isWhitelistOnly() {
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.networking.v1.ServerPlayConnectionEvents;
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.ServerCommandSource;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.text.ClickEvent;
import net.minecraft.text.MutableText;
import net.minecraft.text.Text;
import net.minecraft.util.Formatting;
import net.minecraft.util.Util;
import org.slf4j.Logger;
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.Map;
import java.util.concurrent.CompletableFuture;
import java.util.Optional;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@ -30,105 +38,106 @@ public class PlayerTimeMod implements ModInitializer {
private static PlayerTimeTracker timeTracker;
private static WebServer webServer;
private static ModConfig config;
public static LocalizationManager localizationManager; // 新增本地化管理器
public static LocalizationManager localizationManager;
// TODO 定时保存配置文件没整暂时硬编码
private ScheduledExecutorService scheduler;
private ScheduledFuture<?> saveTask;
private static long AUTO_SAVE_INTERVAL_SECONDS;
private static final String RSS_FEED_URL = "https://git.branulf.top/Branulf/ServerPlayerOnlineTracker/releases.rss";
private static final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2); // One for update check, one for saving
@Override
public void onInitialize() {
try {
LOGGER.info("[在线时间] 初始化 玩家在线时长视奸Mod...");
// 加载配置
config = new ModConfig(FabricLoader.getInstance().getConfigDir());
// 初始化本地化管理器
localizationManager = new LocalizationManager(config.getLanguage());
scheduler = Executors.newSingleThreadScheduledExecutor();
try {
LOGGER.info("[在线时间] 初始化 玩家在线时长视奸Mod");
AUTO_SAVE_INTERVAL_SECONDS = config.getSeconds();
// 检查更新
checkForUpdates();
// 服务器启动中事件
ServerLifecycleEvents.SERVER_STARTING.register(server -> {
timeTracker = new PlayerTimeTracker(server);
LOGGER.info("[在线时间] 服务器启动中...");
timeTracker = new PlayerTimeTracker(server, config);
try {
webServer = new WebServer(timeTracker, config.getWebPort(), server); // 传入 MinecraftServer
webServer = new WebServer(timeTracker, config.getWebPort(), server);
webServer.start();
LOGGER.info("[在线时间] Web服务器在端口 " + config.getWebPort() + "启动");
LOGGER.info("[在线时间] Web服务器在端口 {} 启动", config.getWebPort());
} catch (Exception 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) -> {
if (timeTracker != null) {
// 根据配置决定是否跟踪该玩家
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) -> {
if (timeTracker != null) {
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 -> {
LOGGER.info("[在线时间] 服务器停止 - 正在保存数据");
if (saveTask != null) {
saveTask.cancel(false);
LOGGER.info("[在线时间] 自动保存任务已取消。");
LOGGER.info("[在线时间] 服务器停止 - 正在保存数据...");
if (timeTracker != null) {
for (ServerPlayerEntity player : server.getPlayerManager().getPlayerList()) {
if (timeTracker.isPlayerTracked(player.getUuid())) {
timeTracker.onPlayerLeave(player);
}
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();
timeTracker.saveAll();
}
LOGGER.info("[在线时间] 调度程序关闭");
}
if (webServer != null) {
webServer.stop();
LOGGER.info("[在线时间] Web服务器已停止.");
}
if (timeTracker != null) {
for (ServerPlayerEntity player : server.getPlayerManager().getPlayerList()) {
timeTracker.onPlayerLeave(player);
}
timeTracker.saveAll();
}
scheduler.shutdownNow();
LOGGER.info("[在线时间] 定时任务已关闭.");
LOGGER.info("[在线时间] 数据保存完成Mod 已停止.");
});
} catch (Exception e) {
LOGGER.error("[在线时间] Mod出屎化失败", e);
if (scheduler != null && !scheduler.isShutdown()) {
scheduler.shutdownNow();
}
LOGGER.error("[在线时间] Mod初始化失败", e);
throw new RuntimeException("[在线时间] Mod初始化失败 ", e);
}
registerCommands();
}
@ -144,12 +153,12 @@ public class PlayerTimeMod implements ModInitializer {
return localizationManager;
}
public static void registerCommands() {
// 注册命令
private void registerCommands() {
CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> {
dispatcher.register(CommandManager.literal("onlineTime")
.requires(source -> source.hasPermissionLevel(0))
.executes(context -> showOnlineTime(context.getSource(), 1)) // 默认第一页
.requires(source -> source.hasPermissionLevel(0)) // 任何玩家都可以使用
.executes(context -> showOnlineTime(context.getSource(), 1)) // 默认显示第一页
.then(CommandManager.argument("page", IntegerArgumentType.integer(1))
.executes(context -> showOnlineTime(
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();
if (player == null) return 0;
CompletableFuture.runAsync(() -> {
PlayerTimeTracker tracker = getTimeTracker();
if (tracker != null) {
Map<String, String> stats = tracker.getWhitelistedPlayerStats();
List<String> sorted = stats.entrySet().stream()
.sorted((a, b) -> comparePlayTime(b.getValue(), a.getValue()))
.map(entry -> formatPlayerTime(entry.getKey(), entry.getValue()))
.collect(Collectors.toList());
sendPaginatedMessage(player, sorted, requestedPage);
if (player == null) {
source.sendMessage(Text.literal(localizationManager.getString("playertime.command.error.player_only")).formatted(Formatting.RED));
return 0;
}
}, Util.getMainWorkerExecutor());
Util.getMainWorkerExecutor().execute(() -> {
PlayerTimeTracker tracker = getTimeTracker();
if (tracker == null) {
player.sendMessage(Text.literal(localizationManager.getString("playertime.command.error.not_initialized")).formatted(Formatting.RED), false);
return;
}
List<PlayerTimeTracker.PlayerTimeStatsWithNames> statsList = tracker.getSortedPlayerStats();
sendPaginatedMessage(player, statsList, requestedPage);
});
return 1;
}
private static int comparePlayTime(String a, String b) {
String timeA = a.substring(a.indexOf(':') + 1).trim().split(" \\| ")[0];
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) {
// 发送分页消息
private void sendPaginatedMessage(ServerPlayerEntity player, List<PlayerTimeTracker.PlayerTimeStatsWithNames> statsList, int requestedPage) {
int pageSize = 10;
int totalPages = (lines.size() + pageSize - 1) / pageSize;
page = Math.max(1, Math.min(page, totalPages));
int totalEntries = statsList.size();
int totalPages = (totalEntries + pageSize - 1) / pageSize;
int currentPage = Math.max(1, Math.min(requestedPage, Math.max(1, totalPages)));
int from = (page - 1) * pageSize;
int to = Math.min(from + pageSize, lines.size());
int fromIndex = (currentPage - 1) * pageSize;
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++) {
player.sendMessage(Text.literal(lines.get(i)), false);
if (statsList.isEmpty()) {
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("");
if (page > 1) {
int finalPage = page;
if (currentPage > 1) {
footer.append(Text.literal(localizationManager.getString("playertime.command.prev_page"))
.styled(style -> style.withClickEvent(new ClickEvent(
ClickEvent.Action.RUN_COMMAND,
"/onlineTime " + (finalPage - 1)
)))
.append(" "));
"/onlineTime " + (currentPage - 1)
)).withColor(Formatting.GREEN)));
}
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;
footer.append(" ").append(Text.literal(localizationManager.getString("playertime.command.next_page"))
if (currentPage < totalPages) {
if (currentPage > 1 || totalEntries > 0) {
footer.append(" ");
}
footer.append(Text.literal(localizationManager.getString("playertime.command.next_page"))
.styled(style -> style.withClickEvent(new ClickEvent(
ClickEvent.Action.RUN_COMMAND,
"/onlineTime " + (finalPage1 + 1)
))));
"/onlineTime " + (currentPage + 1)
)).withColor(Formatting.GREEN)));
}
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.mojang.authlib.GameProfile;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.PlayerManager;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.util.Uuids;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.Collectors;
public class PlayerTimeTracker {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private final MinecraftServer server;
private final Path dataFile;
private final ExecutorService executor = Executors.newSingleThreadExecutor();
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.config = config;
this.dataFile = server.getRunDirectory().resolve("player_time_data.json");
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() {
public void loadData() {
if (!Files.exists(dataFile)) {
PlayerTimeMod.LOGGER.info("[在线时间] 数据文件未找到,跳过加载");
PlayerTimeMod.LOGGER.info("[在线时间] 数据文件未找到 ({}),跳过加载。", dataFile);
return;
}
try (Reader reader = Files.newBufferedReader(dataFile)) {
try (Reader reader = Files.newBufferedReader(dataFile, StandardCharsets.UTF_8)) {
JsonElement jsonElement = JsonParser.parseReader(reader);
if (jsonElement == null || !jsonElement.isJsonObject()) {
PlayerTimeMod.LOGGER.warn("[在线时间] 数据文件为空或格式错误,跳过加载");
PlayerTimeMod.LOGGER.warn("[在线时间] 数据文件 ({}) 为空或格式错误,跳过加载。", dataFile);
return;
}
JsonObject root = jsonElement.getAsJsonObject();
int resetCount = 0;
int loadedCount = 0;
for (Map.Entry<String, JsonElement> entry : root.entrySet()) {
try {
UUID uuid = UUID.fromString(entry.getKey());
PlayerTimeData data = GSON.fromJson(entry.getValue(), PlayerTimeData.class);
// 检查并重置上次登录时间防止服务器崩溃导致时间计算错误
if (data.lastLogin > 0) {
PlayerTimeMod.LOGGER.warn(
"[在线时间] 在数据加载过程中发现玩家{}UUID{}的最后登录时间大于0{}。将其重置为0。",
getPlayerName(uuid), uuid, data.lastLogin
"[在线时间] 在数据加载过程中发现玩家UUID{}的最后登录时间大于0{}。将其重置为0。",
uuid, data.lastLogin
);
data.lastLogin = 0;
resetCount++;
}
playerData.put(uuid, data);
loadedCount++;
} catch (IllegalArgumentException e) {
PlayerTimeMod.LOGGER.error("[在线时间] I数据文件中的 UUID 格式无效: " + entry.getKey(), e);
PlayerTimeMod.LOGGER.error("[在线时间] 数据文件中的 UUID 格式无效: " + entry.getKey(), e);
} catch (JsonParseException e) {
PlayerTimeMod.LOGGER.error("[在线时间] 解析玩家数据失败(UUID: " + entry.getKey() + ")", e);
}
}
PlayerTimeMod.LOGGER.info("[在线时间] 成功加载了 {} 名玩家的数据,重置了 {} 名玩家的上次登录时间", playerData.size(), resetCount);
PlayerTimeMod.LOGGER.info("[在线时间] 成功加载了 {} 名玩家的数据,重置了 {} 名玩家的上次登录时间。", loadedCount, resetCount);
} catch (IOException e) {
PlayerTimeMod.LOGGER.error("[在线时间] 无法读取玩家在线时间数据文件", e);
PlayerTimeMod.LOGGER.error("[在线时间] 无法读取玩家在线时间数据文件 ({})", dataFile, e);
} catch (JsonParseException e) {
PlayerTimeMod.LOGGER.error("[在线时间] 玩家在线时间数据文件格式错误", e);
PlayerTimeMod.LOGGER.error("[在线时间] 玩家在线时间数据文件 ({}) 格式错误", dataFile, e);
} catch (Exception 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() {
PlayerTimeMod.LOGGER.info("[在线时间] 开始保存所有玩家数据...");
JsonObject root = new JsonObject();
playerData.forEach((uuid, data) -> {
if (data.lastLogin > 0) {
long now = Instant.now().getEpochSecond();
JsonObject root = new JsonObject();
int savedCount = 0;
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;
if (sessionTime > 0) {
data.totalTime += sessionTime;
data.rolling30Days.addPlayTime(now, sessionTime);
data.rolling7Days.addPlayTime(now, sessionTime);
PlayerTimeMod.LOGGER.debug("[在线时间] 累积当前会话时间 {} 秒,保存期间玩家 {}", sessionTime, getPlayerName(uuid));
// 更新 lastLogin 到当前时间防止炸鱼
data.lastLogin = now;
PlayerTimeMod.LOGGER.debug("[在线时间] 定时保存:更新玩家 {} ({}) 会话时间 {} 秒。", player.getName().getString(), uuid, sessionTime);
} else {
PlayerTimeMod.LOGGER.debug("[在线时间] 定时保存:玩家 {} ({}) 会话时间不正常 ({}),跳过更新。", player.getName().getString(), uuid, sessionTime);
}
data.lastLogin = 0;
}
}
// 遍历所有玩家数据进行保存
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));
});
try (Writer writer = Files.newBufferedWriter(dataFile)) {
GSON.toJson(root, writer);
PlayerTimeMod.LOGGER.info("[在线时间] 所有玩家数据已成功保存");
} catch (Exception e) {
PlayerTimeMod.LOGGER.error("[在线时间] 无法保存所有玩家在线时间数据", e);
}
savedCount++;
}
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();
Path dataDir = dataFile.getParent();
if (dataDir != null) {
Files.createDirectories(dataDir);
} else {
root = new JsonObject();
}
}
} else {
root = new JsonObject();
}
} catch (Exception e) {
PlayerTimeMod.LOGGER.error("[在线时间] 在异步保存期间无法读取数据文件,正在创建新对象", e);
root = new JsonObject();
PlayerTimeMod.LOGGER.debug("[在线时间] 数据文件 {} 位于根目录,跳过创建父目录。", dataFile);
}
root.add(uuid.toString(), GSON.toJsonTree(data));
Path tempFile = dataFile.resolveSibling(dataFile.getFileName().toString() + ".tmp");
try (Writer writer = Files.newBufferedWriter(dataFile)) {
try (Writer writer = Files.newBufferedWriter(tempFile, StandardCharsets.UTF_8)) {
GSON.toJson(root, writer);
// PlayerTimeMod.LOGGER.debug("[在线时间] Async save successful for player {}", getPlayerName(uuid));
} catch (Exception e) {
PlayerTimeMod.LOGGER.error("[在线时间] 无法异步保存玩家的在线时间数据 " + getPlayerName(uuid) + " (UUID: " + uuid + ")", e);
}
});
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) {
@ -269,48 +304,56 @@ public class PlayerTimeTracker {
}
public static class PlayerTimeData {
long totalTime = 0;
long lastLogin = 0;
RollingTimeWindow rolling30Days = new RollingTimeWindow(30);
RollingTimeWindow rolling7Days = new RollingTimeWindow(7);
}
public static class PlayerTimeStats {
public long totalTime;
public long last30Days;
public long last7Days;
long totalTime = 0; // 总在线时间
long lastLogin = 0; // 上次登录时间戳
RollingTimeWindow rolling30Days = new RollingTimeWindow(30); // 最近30天
RollingTimeWindow rolling7Days = new RollingTimeWindow(7); // 最近7天
}
private static class RollingTimeWindow {
private final int days;
private final List<TimeEntry> entries = new LinkedList<>();
@SuppressWarnings("unused")
private RollingTimeWindow() {
this.days = 0;
}
public RollingTimeWindow(int days) {
this.days = days;
}
public void addPlayTime(long timestamp, long seconds) {
if (seconds <= 0) return;
entries.add(new TimeEntry(timestamp, seconds));
cleanUp(timestamp);
}
public long getTotalTime(long currentTime) {
cleanUp(currentTime);
return entries.stream().mapToLong(e -> e.seconds).sum();
}
private void cleanUp(long currentTime) {
long cutoff = currentTime - (days * 24 * 3600L);
while (!entries.isEmpty() && entries.get(0).timestamp < cutoff) {
entries.remove(0);
}
entries.removeIf(entry -> entry.timestamp < cutoff);
}
private static class TimeEntry {
final long timestamp;
final long seconds;
// Gson 需要一个无参构造函数
@SuppressWarnings("unused")
private TimeEntry() {
this.timestamp = 0;
this.seconds = 0;
}
TimeEntry(long timestamp, long seconds) {
this.timestamp = timestamp;
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() {
return Collections.unmodifiableMap(playerData);
}
@ -326,4 +393,103 @@ public class PlayerTimeTracker {
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;
import com.google.gson.*;
import com.mojang.authlib.GameProfile;
import com.sun.net.httpserver.HttpServer;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpExchange;
@ -20,6 +19,12 @@ import java.util.*;
import java.util.concurrent.*;
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 {
@ -28,12 +33,13 @@ public class WebServer {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private final ExecutorService executor = Executors.newFixedThreadPool(4);
private final MinecraftServer minecraftServer;
private static final Map<String, String> MIME_TYPES = Map.ofEntries(
Map.entry("html", "text/html"),
Map.entry("css", "text/css"),
Map.entry("js", "application/javascript"),
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("jpeg", "image/jpeg"),
Map.entry("gif", "image/gif"),
@ -41,7 +47,8 @@ public class WebServer {
Map.entry("woff", "application/font-woff"),
Map.entry("woff2", "application/font-woff2"),
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 {
@ -54,7 +61,9 @@ public class WebServer {
setupContexts();
}
// 创建HTTP服务器
private void setupContexts() {
// API: 获取玩家统计数据
server.createContext("/api/stats", exchange -> {
handleCors(exchange);
if ("OPTIONS".equals(exchange.getRequestMethod())) {
@ -68,17 +77,31 @@ public class WebServer {
}
try {
// 白名单
Map<String, String> stats = timeTracker.getWhitelistedPlayerStats();
String response = GSON.toJson(stats);
List<PlayerTimeTracker.PlayerTimeStatsWithNames> statsList = timeTracker.getSortedPlayerStats();
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");
PlayerTimeMod.LOGGER.debug("[在线时间] API /api/stats 请求成功,返回 {} 条玩家数据。", responseList.size());
} 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");
}
});
// 语言文件内容
// API: 获取语言文件内容不公开
server.createContext("/api/lang", exchange -> {
handleCors(exchange);
if ("OPTIONS".equals(exchange.getRequestMethod())) {
@ -92,62 +115,194 @@ public class WebServer {
}
try {
String langCode = PlayerTimeMod.getConfig().getLanguage();
String resourcePath = String.format("assets/playertime/lang/%s.json", langCode);
String finalResourcePath1 = resourcePath;
InputStream is = FabricLoader.getInstance().getModContainer("playertime")
.flatMap(container -> container.findPath(finalResourcePath1))
.map(path -> {
try {
return path.toUri().toURL().openStream();
Map<String, String> langMap = PlayerTimeMod.getLocalizationManager().getAllStrings();
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) {
return null;
PlayerTimeMod.LOGGER.error("[在线时间] 处理 /api/lang 请求失败", e);
sendResponse(exchange, 500, "Internal Server Error");
}
})
.orElse(null);
});
if (is == null) {
// Fallback to default language if configured language file is not found
langCode = "en_us"; // Default fallback
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) {
PlayerTimeMod.LOGGER.error("[PlayerTime] Default language file (en_us.json) not found!");
sendResponse(exchange, 500, "Language file not found");
// API: 获取在线玩家列表
server.createContext("/api/online-players", exchange -> {
handleCors(exchange);
if ("OPTIONS".equals(exchange.getRequestMethod())) {
exchange.sendResponseHeaders(204, -1);
return;
}
PlayerTimeMod.LOGGER.warn("[PlayerTime] Configured language file ({}.json) not found, using default (en_us.json).", PlayerTimeMod.getConfig().getLanguage());
if (!"GET".equals(exchange.getRequestMethod())) {
sendResponse(exchange, 405, "Method Not Allowed");
return;
}
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);
try {
Map<String, List<Map<String, String>>> onlinePlayers = timeTracker.getOnlinePlayersCategorized();
OnlinePlayersResponse response = new OnlinePlayersResponse(onlinePlayers.get("whitelisted"), onlinePlayers.get("non_whitelisted"));
sendResponse(exchange, 200, GSON.toJson(response).getBytes(StandardCharsets.UTF_8), "application/json");
PlayerTimeMod.LOGGER.debug("[在线时间] API /api/online-players 请求成功。");
} catch (Exception e) {
PlayerTimeMod.LOGGER.error("[在线时间] 处理 /api/online-players 请求失败", e);
sendResponse(exchange, 500, "Internal Server Error");
}
buffer.flush();
is.close();
});
sendResponse(exchange, 200, buffer.toByteArray(), "application/json");
PlayerTimeMod.LOGGER.debug("[PlayerTime] Served language file: {}", resourcePath);
// 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);
}
status.server.version = minecraftServer.getVersion();
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) {
PlayerTimeMod.LOGGER.error("[PlayerTime] Failed to read language file", e);
sendResponse(exchange, 500, "Error reading language file");
PlayerTimeMod.LOGGER.error("[在线时间] 读取玩家数据文件时出错", e);
sendResponse(exchange, 500, "Error reading data file");
} 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");
}
});
@ -157,19 +312,16 @@ public class WebServer {
server.createContext("/", exchange -> {
try {
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;
if (requestPath.equals("/")) {
resourcePath += resourceFileName;
resourcePath += "index.html";
}
if (resourcePath.contains("..")) {
sendResponse(exchange, 403, "Forbidden");
return;
}
String finalResourcePath = resourcePath;
InputStream is = FabricLoader.getInstance().getModContainer("playertime")
@ -185,22 +337,39 @@ public class WebServer {
if (is == null) {
PlayerTimeMod.LOGGER.warn("[PlayerTime] Static resource not found: {}", resourcePath);
if (!requestPath.equals("/") && !requestPath.contains(".")) {
PlayerTimeMod.LOGGER.debug("[在线时间] 找不到静态资源:{},尝试提供 index.html 作为 fallback。", resourcePath);
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 = "";
int dotIndex = resourceFileName.lastIndexOf('.');
if (dotIndex > 0 && dotIndex < resourceFileName.length() - 1) {
extension = resourceFileName.substring(dotIndex + 1).toLowerCase();
int dotIndex = fileName.lastIndexOf('.');
if (dotIndex > 0 && dotIndex < fileName.length() - 1) {
extension = fileName.substring(dotIndex + 1).toLowerCase();
}
String contentType = MIME_TYPES.getOrDefault(extension, "application/octet-stream");
// 读取文件内容
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
byte[] data = new byte[1024];
byte[] data = new byte[4096];
int nRead;
while ((nRead = is.read(data, 0, data.length)) != -1) {
buffer.write(data, 0, nRead);
@ -209,334 +378,10 @@ public class WebServer {
is.close();
sendResponse(exchange, 200, buffer.toByteArray(), contentType);
PlayerTimeMod.LOGGER.debug("[PlayerTime] Served static file: {}", resourcePath);
PlayerTimeMod.LOGGER.debug("[在线时间] 提供静态文件: {}", resourcePath);
} catch (Exception e) {
PlayerTimeMod.LOGGER.error("[PlayerTime] Failed to serve static resource", 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);
PlayerTimeMod.LOGGER.error("[在线时间] 无法提供静态资源", e);
sendResponse(exchange, 500, "Internal Server Error");
}
});
@ -545,12 +390,14 @@ public class WebServer {
server.setExecutor(executor);
}
// 跨域处理
private void handleCors(HttpExchange exchange) {
exchange.getResponseHeaders().add("Access-Control-Allow-Origin", "*");
exchange.getResponseHeaders().add("Access-Control-Allow-Methods", "GET, OPTIONS");
exchange.getResponseHeaders().add("Access-Control-Allow-Headers", "Content-Type");
}
// 发送响应
private void sendResponse(HttpExchange exchange, int code, String response) throws IOException {
sendResponse(exchange, code, response.getBytes(StandardCharsets.UTF_8), "text/plain");
}
@ -563,16 +410,26 @@ public class WebServer {
}
}
// 启动
public void start() {
server.start();
}
// 停止
public void stop() {
server.stop(0);
server.stop(3);
executor.shutdown();
try {
if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
executor.shutdownNow(); // 强制关闭
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}
}
// 格式化时间
private String formatUptime(long millis) {
long seconds = millis / 1000;
long days = seconds / 86400;
@ -582,6 +439,12 @@ public class WebServer {
long minutes = 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.30day": "Last 30 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.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.performance": "Real-time Performance",
"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.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_7_days": "Last 7 Days",
"playertime.web.stats_table.empty": "No player data available",
"playertime.web.chart.memory_used": "Used",
"playertime.web.chart.memory_free": "Free",
"playertime.web.chart.tps": "TPS",
"playertime.web.chart.mspt": "MSPT",
"playertime.web.chart.disk_used": "Used",
"playertime.web.chart.disk_free": "Free",
"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.30day": "30天",
"playertime.stats.7day": "7天",
"playertime.command.empty_stats": "暂无玩家在线时间数据。",
"playertime.command.error.not_initialized": "在线时间Mod未完全初始化请稍后再试。",
"playertime.command.error.player_only": "此命令只能由玩家执行。",
"playertime.web.title": "玩家在线及状态统计",
"playertime.web.warning": "数据统计时间开始于此MOD安装时间不包含安装之前的所有数据",
@ -27,6 +31,17 @@
"playertime.web.server_status.memory_percent": "使用率",
"playertime.web.server_status.performance": "实时性能",
"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.info": "仅跟踪和显示列入白名单的玩家",
@ -35,12 +50,18 @@
"playertime.web.stats_table.header.last_30_days": "最近 30 天",
"playertime.web.stats_table.header.last_7_days": "最近 7 天",
"playertime.web.stats_table.empty": "暂无玩家数据",
"playertime.web.chart.memory_used": "已使用",
"playertime.web.chart.memory_free": "未使用",
"playertime.web.chart.tps": "TPS",
"playertime.web.chart.mspt": "MSPT",
"playertime.web.chart.disk_used": "已用",
"playertime.web.chart.disk_free": "可用",
"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,111 +1,232 @@
<!DOCTYPE html>
<html lang="en">
<html lang="zh_cn" data-theme="light">
<head>
<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>
<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">
<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/chartjs-adapter-date-fns/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
</head>
<body>
<div class="container">
<div class="header">
<h1 data-lang-key="playertime.web.title">玩家在线时间统计</h1>
<div class="app-container">
<!-- 顶部导航栏 -->
<nav class="navbar">
<div class="navbar-content">
<div class="navbar-left">
<h1 class="app-title" data-lang-key="playertime.web.title">玩家在线时间统计</h1>
</div>
<div class="navbar-right">
<button id="theme-toggle" class="theme-toggle" aria-label="切换主题">
<i class="fas fa-moon"></i>
<i class="fas fa-sun"></i>
<i class="fas fa-moon theme-icon"></i>
<i class="fas fa-sun theme-icon"></i>
</button>
</div>
<div class="warning" data-lang-key="playertime.web.warning">数据统计时间开始于此MOD安装时间不包含安装之前的所有数据</div>
<br>
</div>
</nav>
<!-- 在线列表-->
<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 class="main-content">
<!-- 警告信息 -->
<div class="notification warning" data-lang-key="playertime.web.warning">
<i class="fas fa-exclamation-circle"></i>
<span>数据统计时间开始于此MOD安装时间不包含安装之前的所有数据</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>
<!-- 在线玩家卡片 -->
<section class="dashboard-section">
<div class="section-header">
<h2>在线状态</h2>
</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 class="stats-grid">
<!-- 玩家计数卡片 -->
<div class="stat-card accent-blue">
<div class="stat-icon">
<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 class="status-card">
<h3 data-lang-key="playertime.web.status.online_list">在线玩家列表</h3>
<div class="player-lists">
<div class="player-list">
<h4 data-lang-key="playertime.web.status.online_list.players">玩家</h4>
<ul class="online-players" id="whitelist-players"></ul>
</div>
<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 class="player-lists-container">
<div class="player-list-card">
<div class="player-list-header">
<h3 data-lang-key="playertime.web.status.online_list.players">玩家</h3>
<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>
<!-- 服务器状态 -->
<div class="status-section">
<section class="dashboard-section">
<div class="section-header">
<h2 data-lang-key="playertime.web.server_status.title">服务器状态</h2>
<div class="status-grid">
<div class="status-item">
<h3 data-lang-key="playertime.web.server_status.memory_usage">内存使用</h3>
<canvas id="memory-chart"></canvas>
<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><span data-lang-key="playertime.web.server_status.memory_free">可用</span>: <span id="memory-free">0</span> MB</div>
<div><span data-lang-key="playertime.web.server_status.memory_percent">使用率</span>: <span id="memory-percent">0</span>%</div>
<div class="uptime-display">
<i class="fas fa-clock"></i>
<span id="uptime">加载中...</span>
</div>
</div>
<div class="status-item">
<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 class="chart-container">
<div class="metric-display">
<div class="metric">
<span class="metric-label" data-lang-key="playertime.web.chart.tps">TPS:</span>
</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>
</div>
<div class="metric">
<span class="metric-label" data-lang-key="playertime.web.chart.mspt">MSPT:</span>
<div class="metric-card">
<span class="metric-label" data-lang-key="playertime.web.chart.mspt">MSPT</span>
<span class="metric-value" id="mspt-value">0.0</span>
</div>
</div>
<div class="chart-wrapper">
<div class="chart-container">
<canvas id="performance-chart"></canvas>
</div>
</div>
</div>
</section>
</div>
</div>
<div class="status-item full-width">
<h3 data-lang-key="playertime.web.server_status.uptime">服务器运行时间</h3>
<p id="uptime">加载中...</p>
</div>
<!-- 在线时长 -->
<!-- 玩家在线时长 -->
<section class="dashboard-section">
<div class="section-header">
<h2 data-lang-key="playertime.web.stats_table.title">玩家在线时长 (白名单)</h2>
<div class="controls">
<button id="refresh-btn" class="refresh-btn">
<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>
<p class="info-note" data-lang-key="playertime.web.stats_table.info">仅跟踪和显示列入白名单的玩家</p>
</div>
<div class="stats-container">
</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>
@ -118,13 +239,25 @@
<tbody></tbody>
</table>
</div>
</section>
</div>
<script src="js/app.js"></script>
<footer class="license-footer">
<footer class="footer">
<div class="footer-content">
<div class="license-info">
<p>本项目基于 GPL3许可证 开源</p>
<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>
</footer>
</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>
</body>
</html>

View File

@ -1,12 +1,14 @@
document.addEventListener('DOMContentLoaded', function() {
// 初始化变量
const tpsHistory = Array(30).fill(20);
const msptHistory = Array(30).fill(50);
const tpsHistory = Array(60).fill(20);
const msptHistory = Array(60).fill(0);
const performanceLabels = Array(60).fill('');
let memoryChart = null;
let performanceChart = null;
let diskChart = null;
let lang = {};
// 获取语言
// DOM元素
const elements = {
refreshBtn: document.getElementById('refresh-btn'),
themeToggle: document.getElementById('theme-toggle'),
@ -20,25 +22,36 @@ document.addEventListener('DOMContentLoaded', function() {
memoryUsed: document.getElementById('memory-used'),
memoryFree: document.getElementById('memory-free'),
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'),
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';
document.documentElement.setAttribute('data-theme', savedTheme);
// 加载语言
// 初始设置:加载语言,初始化图表,加载数据,设置刷新间隔
loadLanguage().then(() => {
translatePage();
initCharts();
loadAllData();
// 定时刷新
// 设置定期刷新每10秒一次后续加入配置文件
const refreshInterval = setInterval(loadAllData, 10000);
console.log('数据刷新间隔设置为10秒。');
}).catch(error => {
console.error('Failed to load language or initial data:', error);
showError('Failed to load language or initial data.');
console.error('加载语言或初始数据失败:', error);
showError('加载语言或初始数据失败。请检查控制台。');
});
if (elements.themeToggle) {
@ -49,18 +62,24 @@ document.addEventListener('DOMContentLoaded', function() {
elements.refreshBtn.addEventListener('click', handleRefresh);
}
if (elements.floatingRefreshBtn) {
elements.floatingRefreshBtn.addEventListener('click', handleRefresh);
}
// 后端加载语言
async function loadLanguage() {
try {
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();
console.log('Language file loaded.');
console.log('语言文件加载成功。');
} catch (error) {
console.error('Failed to load language file:', error);
console.error('无法从/api/lang加载语言文件:', error);
lang = {};
throw error;
}
}
@ -74,11 +93,27 @@ document.addEventListener('DOMContentLoaded', function() {
element.textContent = lang[key];
}
} 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) {
const pattern = lang[key] || key;
try {
@ -88,141 +123,13 @@ document.addEventListener('DOMContentLoaded', function() {
});
return result;
} catch (e) {
console.error(`Failed to format string for key: ${key}`, e);
console.error(`格式化键的字符串失败: ${key}`, e);
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() {
const currentTheme = document.documentElement.getAttribute('data-theme');
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
@ -230,8 +137,11 @@ document.addEventListener('DOMContentLoaded', function() {
localStorage.setItem('theme', newTheme);
}
// 防呆
function handleRefresh() {
if (this.classList.contains('loading')) return;
this.classList.add('loading');
loadAllData().finally(() => {
setTimeout(() => {
@ -240,6 +150,7 @@ document.addEventListener('DOMContentLoaded', function() {
});
}
async function loadAllData() {
try {
const [statsData, onlinePlayersData, playerCountsData, serverStatusData] = await Promise.all([
@ -263,8 +174,8 @@ document.addEventListener('DOMContentLoaded', function() {
async function fetchData(url) {
const response = await fetch(url);
if (!response.ok) {
const errorBody = await response.text().catch(() => 'Unknown Error');
throw new Error(`HTTP error! status: ${response.status}, URL: ${url}, Body: ${errorBody}`);
const errorBody = await response.text().catch(() => '未知错误');
throw new Error(`HTTP错误! 状态: ${response.status}, URL: ${url}, 内容: ${errorBody}`);
}
return response.json();
}
@ -273,6 +184,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (!elements.statsTableBody || !elements.statsTableHeader) return;
elements.statsTableBody.innerHTML = '';
const headers = elements.statsTableHeader.querySelectorAll('th[data-lang-key]');
headers.forEach(th => {
const key = th.getAttribute('data-lang-key');
@ -281,41 +193,7 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
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) {
if (!Array.isArray(data) || data.length === 0) {
const row = elements.statsTableBody.insertRow();
const cell = row.insertCell(0);
cell.colSpan = 4;
@ -325,16 +203,16 @@ document.addEventListener('DOMContentLoaded', function() {
return;
}
sortedPlayers.forEach(player => {
data.forEach(player => {
const row = elements.statsTableBody.insertRow();
row.insertCell(0).textContent = player.name;
row.insertCell(1).textContent = player.stats.totalTime;
row.insertCell(2).textContent = player.stats['30Days'];
row.insertCell(3).textContent = player.stats['7Days'];
row.insertCell(0).textContent = player.playerName;
row.insertCell(1).textContent = player.totalTimeFormatted;
row.insertCell(2).textContent = player.last30DaysFormatted;
row.insertCell(3).textContent = player.last7DaysFormatted;
});
}
// 更新在线玩家列表
function updateOnlinePlayers(data) {
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');
}
// 更新在线玩家计数
function updatePlayerCounts(data) {
if (elements.totalCount) elements.totalCount.textContent = data.total || 0;
if (elements.whitelistCount) elements.whitelistCount.textContent = data.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) {
if (data.memory) {
const usedMB = Math.round(data.memory.used / (1024 * 1024));
const freeMB = Math.round((data.memory.max - data.memory.used) / (1024 * 1024));
const freeMB = Math.round(data.memory.free / (1024 * 1024));
const percent = Math.round(data.memory.usage_percentage);
if (memoryChart) {
@ -378,55 +261,285 @@ document.addEventListener('DOMContentLoaded', function() {
if (elements.memoryPercent) elements.memoryPercent.textContent = percent;
}
// 更新性能统计和图表TPS/MSPT
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.toFixed(1);
const mspt = data.server.average_tick_time_ms || 0;
const tps = Math.min(20, 1000 / (mspt || 1)).toFixed(1);
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) {
tpsHistory.shift();
tpsHistory.push(parseFloat(tps));
msptHistory.shift();
msptHistory.push(parseFloat(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');
msptHistory.push(mspt);
performanceChart.data.datasets[0].data = tpsHistory;
performanceChart.data.datasets[1].data = msptHistory;
performanceChart.update('none');
setMetricColor(elements.tpsValue, tps, 15, 10, false);
setMetricColor(elements.msptValue, mspt, 50, 100, true);
setMetricColor(elements.tpsValue, parseFloat(tps), 18, 15, false);
setMetricColor(elements.msptValue, mspt, 50, 60, true);
}
}
// 更新磁盘统计和图表
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`;
}
}
}
}
}
});
}
if (elements.uptime) elements.uptime.textContent = data.uptime_formatted || '0';
// 性能图表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()
}
}
}
}
});
}
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;
// 磁盘图表
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()
}
if (parts[2]) { // min
seconds += parseInt(parts[2].replace('m', '')) * 60;
},
tooltip: {
callbacks: {
label: function(context) {
const label = context.label || '';
const value = context.raw;
return `${label}: ${value} GB`;
}
}
return seconds;
}
}
}
});
}
}
function showError(message) {
const errorEl = document.createElement('div');
@ -449,24 +562,28 @@ document.addEventListener('DOMContentLoaded', function() {
const numValue = parseFloat(value);
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) {
color = '#4CAF50'; // Green
color = successColor;
} else if (numValue <= badThreshold) {
color = '#FFC107'; // Yellow
color = warningColor;
} else {
color = '#FF5722'; // Red
color = dangerColor;
}
} else {
} else { // 值越高越好例如TPS
if (numValue >= goodThreshold) {
color = '#4CAF50'; // Green
color = successColor;
} else if (numValue >= badThreshold) {
color = '#FFC107'; // Yellow
color = warningColor;
} 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",
"sources": "https://git.branulf.top/Branulf/ServerPlayerOnlineTracker"
},
"icon": "assets/playertime/icon540.png",
"license": "GPL3",
"environment": "server",
"entrypoints": {

View File

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