diff --git a/gradle.properties b/gradle.properties index f4240c3..456764f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,7 @@ minecraft_version=1.21.4 yarn_mappings=1.21.4+build.8 loader_version=0.16.10 # Mod Properties -mod_version=1.14.514.121 +mod_version=1.14.514.128 maven_group=org.example1 archives_base_name=playerOnlineTimeTrackerMod # Dependencies diff --git a/src/main/java/com/example/playertime/LocalizationManager.java b/src/main/java/com/example/playertime/LocalizationManager.java new file mode 100644 index 0000000..d8105c9 --- /dev/null +++ b/src/main/java/com/example/playertime/LocalizationManager.java @@ -0,0 +1,84 @@ +package com.example.playertime; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import net.fabricmc.loader.api.FabricLoader; + +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.text.MessageFormat; +import java.util.HashMap; +import java.util.Map; + +// 这个是gemini写的 +public class LocalizationManager { + private final Map translations = new HashMap<>(); + private final String defaultLanguage = "zh_cn"; + + public LocalizationManager(String languageCode) { + loadLanguage(languageCode); + if (!languageCode.equals(defaultLanguage)) { + loadLanguage(defaultLanguage, true); + } + } + + 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") + .flatMap(container -> container.findPath(resourcePath)) + .map(path -> { + try { + return path.toUri().toURL().openStream(); + } catch (Exception e) { + return null; + } + }) + .orElse(null)) { + + if (is == null) { + if (!isFallback) { + PlayerTimeMod.LOGGER.warn("[PlayerTime] Language file not found for code: {}", languageCode); + } + return; + } + + 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 + translations.put(entry.getKey(), entry.getValue().getAsString()); + } + }); + PlayerTimeMod.LOGGER.info("[PlayerTime] Loaded {} language strings for code: {}", translations.size(), languageCode); + } + + } catch (Exception e) { + PlayerTimeMod.LOGGER.error("[PlayerTime] Failed to load language file for code: {}", languageCode, e); + } + } + + public String getString(String key) { + return translations.getOrDefault(key, key); + } + + public String getString(String key, Object... args) { + String pattern = getString(key); + try { + return MessageFormat.format(pattern, args); + } catch (IllegalArgumentException e) { + PlayerTimeMod.LOGGER.error("[PlayerTime] Failed to format localization string for key: {}", key, e); + return pattern; + } + } +} diff --git a/src/main/java/com/example/playertime/ModConfig.java b/src/main/java/com/example/playertime/ModConfig.java index 7ad1c60..9577d13 100644 --- a/src/main/java/com/example/playertime/ModConfig.java +++ b/src/main/java/com/example/playertime/ModConfig.java @@ -8,6 +8,7 @@ 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"; public ModConfig(Path configDir) { this.configPath = configDir.resolve("playertime-config.json"); @@ -16,35 +17,62 @@ public class ModConfig { private void loadConfig() { if (!Files.exists(configPath)) { + PlayerTimeMod.LOGGER.info("[在线时间] 配置文件未找到,正在创建默认配置"); // 使用英文日志,或者也本地化日志?这里先用英文 saveConfig(); return; } try (Reader reader = Files.newBufferedReader(configPath)) { - JsonObject json = JsonParser.parseReader(reader).getAsJsonObject(); + JsonElement jsonElement = JsonParser.parseReader(reader); + + if (jsonElement == null || !jsonElement.isJsonObject()) { + PlayerTimeMod.LOGGER.warn("[在线时间] 配置文件为空或格式错误,正在使用默认配置并覆盖"); + saveConfig(); + return; + } + + JsonObject json = jsonElement.getAsJsonObject(); + if (json.has("webPort")) { webPort = json.get("webPort").getAsInt(); } + if (json.has("language")) { + language = json.get("language").getAsString(); + } else { + PlayerTimeMod.LOGGER.info("[在线时间] 配置文件缺少“language”字段,添加默认值'%s'并保存", language); + saveConfig(); + } + } catch (Exception e) { - PlayerTimeMod.LOGGER.error("[在线时间] 加载配置失败", e); + PlayerTimeMod.LOGGER.error("[在线时间] 加载配置文件失败,使用默认配置", e); + saveConfig(); } } + // 保存配置 private void saveConfig() { JsonObject json = new JsonObject(); json.addProperty("webPort", webPort); + json.addProperty("language", language); try { Files.createDirectories(configPath.getParent()); try (Writer writer = Files.newBufferedWriter(configPath)) { GSON.toJson(json, writer); } + PlayerTimeMod.LOGGER.info("[在线时间] 配置已成功保存"); } catch (Exception e) { PlayerTimeMod.LOGGER.error("[在线时间] 保存配置失败", e); } } + // 获取端口 public int getWebPort() { return webPort; } -} \ No newline at end of file + + // 获取语言 + public String getLanguage() { + return language; + } +} diff --git a/src/main/java/com/example/playertime/PlayerTimeMod.java b/src/main/java/com/example/playertime/PlayerTimeMod.java index 502b5f2..04bc05d 100644 --- a/src/main/java/com/example/playertime/PlayerTimeMod.java +++ b/src/main/java/com/example/playertime/PlayerTimeMod.java @@ -19,6 +19,10 @@ import org.slf4j.LoggerFactory; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; +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; public class PlayerTimeMod implements ModInitializer { @@ -26,26 +30,49 @@ public class PlayerTimeMod implements ModInitializer { private static PlayerTimeTracker timeTracker; private static WebServer webServer; private static ModConfig config; + public static LocalizationManager localizationManager; // 新增本地化管理器 + + // TODO 定时保存配置文件没整,暂时硬编码 + private ScheduledExecutorService scheduler; + private ScheduledFuture saveTask; + private static final long AUTO_SAVE_INTERVAL_SECONDS = 5 * 60; @Override public void onInitialize() { config = new ModConfig(FabricLoader.getInstance().getConfigDir()); + localizationManager = new LocalizationManager(config.getLanguage()); // 初始化本地化管理器 + + scheduler = Executors.newSingleThreadScheduledExecutor(); try { - LOGGER.info("[在线时间] 初始化玩家在线时长视奸MOD"); - + LOGGER.info("[在线时间] 初始化 玩家在线时长视奸Mod"); ServerLifecycleEvents.SERVER_STARTING.register(server -> { timeTracker = new PlayerTimeTracker(server); try { webServer = new WebServer(timeTracker, config.getWebPort(), server); // 传入 MinecraftServer 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 + ); }); ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> { @@ -61,18 +88,44 @@ public class PlayerTimeMod implements ModInitializer { }); ServerLifecycleEvents.SERVER_STOPPING.register(server -> { - LOGGER.info("[在线时间] 服务器停止 - 保存数据"); + LOGGER.info("[在线时间] 服务器停止 - 正在保存数据"); + + if (saveTask != null) { + saveTask.cancel(false); + LOGGER.info("[在线时间] 自动保存任务已取消。"); + } + if (scheduler != null && !scheduler.isShutdown()) { + scheduler.shutdown(); + try { + if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) { + LOGGER.warn("[在线时间] 调度程序未在5秒内终止。"); + } + } catch (InterruptedException e) { + LOGGER.error("[在线时间] 调度程序终止被中断", e); + Thread.currentThread().interrupt(); // Restore interrupted status + } + LOGGER.info("[在线时间] 调度程序关闭"); + } + + if (webServer != null) { webServer.stop(); } if (timeTracker != null) { + for (ServerPlayerEntity player : server.getPlayerManager().getPlayerList()) { + timeTracker.onPlayerLeave(player); + } timeTracker.saveAll(); } }); + } catch (Exception e) { - LOGGER.error("[在线时间] Mod 初始化失败!", e); - throw new RuntimeException("[在线时间] Mod 初始化失败", e); + LOGGER.error("[在线时间] Mod出屎化失败", e); + if (scheduler != null && !scheduler.isShutdown()) { + scheduler.shutdownNow(); + } + throw new RuntimeException("[在线时间] Mod初始化失败 ", e); } registerCommands(); } @@ -81,12 +134,14 @@ public class PlayerTimeMod implements ModInitializer { return config; } - - public static PlayerTimeTracker getTimeTracker() { return timeTracker; } + public static LocalizationManager getLocalizationManager() { + return localizationManager; + } + public static void registerCommands() { CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> { @@ -124,27 +179,32 @@ public class PlayerTimeMod implements ModInitializer { return 1; } - - private static int comparePlayTime(String a, String b) { - return Long.compare(parseTimeToSeconds(a), parseTimeToSeconds(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.contains("h")) { - seconds += Integer.parseInt(part.replace("h", "")) * 3600; - } else if (part.contains("m")) { - seconds += Integer.parseInt(part.replace("m", "")) * 60; + 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.split(" \\| ")[0]); // 只显示总时长 + return String.format("§e%s§r: %s", name, timeStr); } private static void sendPaginatedMessage(ServerPlayerEntity player, List lines, int page) { @@ -155,19 +215,16 @@ public class PlayerTimeMod implements ModInitializer { int from = (page - 1) * pageSize; int to = Math.min(from + pageSize, lines.size()); - // 标题 - player.sendMessage(Text.literal("§6===== 玩家在线时长 (第 " + page + "/" + totalPages + " 页) ====="), false); + player.sendMessage(Text.literal(localizationManager.getString("playertime.command.title", page, totalPages)), false); - // 内容 for (int i = from; i < to; i++) { player.sendMessage(Text.literal(lines.get(i)), false); } - // 翻页按钮 MutableText footer = Text.literal(""); if (page > 1) { int finalPage = page; - footer.append(Text.literal("§a[上一页]") + footer.append(Text.literal(localizationManager.getString("playertime.command.prev_page")) .styled(style -> style.withClickEvent(new ClickEvent( ClickEvent.Action.RUN_COMMAND, "/onlineTime " + (finalPage - 1) @@ -175,12 +232,11 @@ public class PlayerTimeMod implements ModInitializer { .append(" ")); } - footer.append(Text.literal("§7共 " + lines.size() + " 位玩家")); - // 死了 + footer.append(Text.literal(localizationManager.getString("playertime.command.total_players", lines.size()))); if (page < totalPages) { int finalPage1 = page; - footer.append(" ").append(Text.literal("§a[下一页]") + footer.append(" ").append(Text.literal(localizationManager.getString("playertime.command.next_page")) .styled(style -> style.withClickEvent(new ClickEvent( ClickEvent.Action.RUN_COMMAND, "/onlineTime " + (finalPage1 + 1) @@ -190,4 +246,4 @@ public class PlayerTimeMod implements ModInitializer { player.sendMessage(footer, false); } -} \ No newline at end of file +} diff --git a/src/main/java/com/example/playertime/PlayerTimeTracker.java b/src/main/java/com/example/playertime/PlayerTimeTracker.java index 41bb6bd..6e04a9d 100644 --- a/src/main/java/com/example/playertime/PlayerTimeTracker.java +++ b/src/main/java/com/example/playertime/PlayerTimeTracker.java @@ -27,34 +27,46 @@ public class PlayerTimeTracker { 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)); - } + // 没用了,但是先保留吧 + // 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(); - saveAsync(player.getUuid()); + PlayerTimeMod.LOGGER.info("[在线时间] 玩家 {} 加入, 开始计时", player.getName().getString()); } public void onPlayerLeave(ServerPlayerEntity player) { PlayerTimeData data = playerData.get(player.getUuid()); - if (data != null) { + if (data != null && data.lastLogin > 0) { long now = Instant.now().getEpochSecond(); long sessionTime = now - data.lastLogin; - data.totalTime += sessionTime; - // 维护30天滚动窗口 - data.rolling30Days.addPlayTime(now, sessionTime); - data.rolling7Days.addPlayTime(now, sessionTime); + 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()); } } @@ -68,9 +80,12 @@ public class PlayerTimeTracker { PlayerTimeStats stats = new PlayerTimeStats(); stats.totalTime = data.totalTime; - // 如果玩家在线,添加当前会话时间 - if (data.lastLogin > 0) { - stats.totalTime += (now - data.lastLogin); + // 检查玩家是否当前在线,只在在线时才计算 + 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); @@ -91,33 +106,41 @@ public class PlayerTimeTracker { }); } - // 遍历所有已记录玩家 + // 遍历所有已记录玩家数据 playerData.forEach((uuid, data) -> { + // 只处理白名单玩家 if (whitelistUuids.contains(uuid)) { String playerName = getPlayerName(uuid); long totalTime = data.totalTime; - if (data.lastLogin > 0) { - totalTime += (now - data.lastLogin); + + // 检查玩家是否当前在线,只在在线时才计算 + if (data.lastLogin > 0 && server.getPlayerManager().getPlayer(uuid) != null) { + long currentSessionTime = now - data.lastLogin; + if (currentSessionTime > 0) { + totalTime += currentSessionTime; + } } - stats.put(playerName, "总时长: " + formatTime(totalTime) + - " | 30天: " + formatTime(data.rolling30Days.getTotalTime(now)) + - " | 7天: " + formatTime(data.rolling7Days.getTotalTime(now))); + 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 Optional profile = server.getUserCache().getByUuid(uuid); if (profile.isPresent()) { return profile.get().getName(); @@ -128,62 +151,118 @@ public class PlayerTimeTracker { private void loadData() { if (!Files.exists(dataFile)) { + PlayerTimeMod.LOGGER.info("[在线时间] 数据文件未找到,跳过加载"); return; } try (Reader reader = Files.newBufferedReader(dataFile)) { - JsonObject root = JsonParser.parseReader(reader).getAsJsonObject(); - for (Map.Entry entry : root.entrySet()) { - UUID uuid = UUID.fromString(entry.getKey()); - playerData.put(uuid, GSON.fromJson(entry.getValue(), PlayerTimeData.class)); + JsonElement jsonElement = JsonParser.parseReader(reader); + + if (jsonElement == null || !jsonElement.isJsonObject()) { + PlayerTimeMod.LOGGER.warn("[在线时间] 数据文件为空或格式错误,跳过加载"); + return; } + + JsonObject root = jsonElement.getAsJsonObject(); + int resetCount = 0; + + for (Map.Entry 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 + ); + data.lastLogin = 0; + resetCount++; + } + + playerData.put(uuid, data); + } catch (IllegalArgumentException e) { + PlayerTimeMod.LOGGER.error("[在线时间] I数据文件中的 UUID 格式无效: " + entry.getKey(), e); + } catch (JsonParseException e) { + PlayerTimeMod.LOGGER.error("[在线时间] 解析玩家数据失败(UUID: " + entry.getKey() + ")", e); + } + } + PlayerTimeMod.LOGGER.info("[在线时间] 成功加载了 {} 名玩家的数据,重置了 {} 名玩家的上次登录时间", playerData.size(), resetCount); + + } catch (IOException e) { + PlayerTimeMod.LOGGER.error("[在线时间] 无法读取玩家在线时间数据文件", e); + } catch (JsonParseException e) { + PlayerTimeMod.LOGGER.error("[在线时间] 玩家在线时间数据文件格式错误", e); } catch (Exception e) { - PlayerTimeMod.LOGGER.error("[在线时间] 无法加载玩家在线时间数据", e); + PlayerTimeMod.LOGGER.error("[在线时间] 加载玩家在线时间数据时发生未知错误", e); } } public void saveAll() { + PlayerTimeMod.LOGGER.info("[在线时间] 开始保存所有玩家数据..."); JsonObject root = new JsonObject(); playerData.forEach((uuid, data) -> { + if (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.debug("[在线时间] 累积当前会话时间 {} 秒,保存期间玩家 {}", sessionTime, getPlayerName(uuid)); + } + data.lastLogin = 0; + } 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); + PlayerTimeMod.LOGGER.error("[在线时间] 无法保存所有玩家在线时间数据", e); } } private void saveAsync(UUID uuid) { executor.execute(() -> { + PlayerTimeData data = playerData.get(uuid); + if (data == null) { + return; + } + JsonObject root; try { if (Files.exists(dataFile)) { try (Reader reader = Files.newBufferedReader(dataFile)) { - root = JsonParser.parseReader(reader).getAsJsonObject(); + JsonElement jsonElement = JsonParser.parseReader(reader); + if (jsonElement != null && jsonElement.isJsonObject()) { + root = jsonElement.getAsJsonObject(); + } else { + root = new JsonObject(); + } } } else { root = new JsonObject(); } } catch (Exception e) { + PlayerTimeMod.LOGGER.error("[在线时间] 在异步保存期间无法读取数据文件,正在创建新对象", e); root = new JsonObject(); } - PlayerTimeData data = playerData.get(uuid); - if (data != null) { - root.add(uuid.toString(), GSON.toJsonTree(data)); - } + root.add(uuid.toString(), GSON.toJsonTree(data)); try (Writer writer = Files.newBufferedWriter(dataFile)) { GSON.toJson(root, writer); + // PlayerTimeMod.LOGGER.debug("[在线时间] Async save successful for player {}", getPlayerName(uuid)); } catch (Exception e) { - PlayerTimeMod.LOGGER.error("[在线时间] 无法保存" + uuid + "的在线时间数据", e); + PlayerTimeMod.LOGGER.error("[在线时间] 无法异步保存玩家的在线时间数据 " + getPlayerName(uuid) + " (UUID: " + uuid + ")", e); } }); } public static String formatTime(long seconds) { + if (seconds < 0) seconds = 0; long hours = seconds / 3600; long minutes = (seconds % 3600) / 60; return String.format("%dh %02dm", hours, minutes); @@ -204,13 +283,14 @@ public class PlayerTimeTracker { private static class RollingTimeWindow { private final int days; - private final List entries = new ArrayList<>(); + private final List entries = new LinkedList<>(); 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); } @@ -221,8 +301,10 @@ public class PlayerTimeTracker { } private void cleanUp(long currentTime) { - long cutoff = currentTime - (days * 24 * 3600); - entries.removeIf(entry -> entry.timestamp < cutoff); + long cutoff = currentTime - (days * 24 * 3600L); + while (!entries.isEmpty() && entries.get(0).timestamp < cutoff) { + entries.remove(0); + } } private static class TimeEntry { @@ -235,8 +317,13 @@ public class PlayerTimeTracker { } } } + public Map getPlayerData() { return Collections.unmodifiableMap(playerData); } -} \ No newline at end of file + public Path getDataFile() { + return this.dataFile; + } + +} diff --git a/src/main/java/com/example/playertime/WebServer.java b/src/main/java/com/example/playertime/WebServer.java index edc5728..4a86680 100644 --- a/src/main/java/com/example/playertime/WebServer.java +++ b/src/main/java/com/example/playertime/WebServer.java @@ -5,6 +5,7 @@ import com.mojang.authlib.GameProfile; import com.sun.net.httpserver.HttpServer; import com.sun.net.httpserver.HttpHandler; import com.sun.net.httpserver.HttpExchange; +import net.fabricmc.loader.api.FabricLoader; import net.minecraft.server.MinecraftServer; import net.minecraft.server.PlayerManager; import net.minecraft.server.network.ServerPlayerEntity; @@ -17,8 +18,9 @@ import java.nio.charset.StandardCharsets; import java.nio.file.*; import java.util.*; import java.util.concurrent.*; +import java.util.stream.Collectors; + -import static com.mojang.text2speech.Narrator.LOGGER; public class WebServer { private final HttpServer server; @@ -26,11 +28,20 @@ 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 MIME_TYPES = Map.of( - "html", "text/html", - "css", "text/css", - "js", "application/javascript", - "json", "application/json" + private static final Map 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("jpg", "image/jpeg"), + Map.entry("jpeg", "image/jpeg"), + Map.entry("gif", "image/gif"), + Map.entry("svg", "image/svg+xml"), + 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") ); public WebServer(PlayerTimeTracker timeTracker, int port, MinecraftServer minecraftServer) throws IOException { @@ -45,10 +56,11 @@ public class WebServer { private void setupContexts() { server.createContext("/api/stats", 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"); - + handleCors(exchange); + if ("OPTIONS".equals(exchange.getRequestMethod())) { + exchange.sendResponseHeaders(204, -1); + return; + } if (!"GET".equals(exchange.getRequestMethod())) { sendResponse(exchange, 405, "Method Not Allowed"); @@ -56,34 +68,135 @@ public class WebServer { } try { - // 改为使用新的白名单统计方法 + // 白名单 Map stats = timeTracker.getWhitelistedPlayerStats(); - String response = new Gson().toJson(stats); - sendResponse(exchange, 200, response.getBytes(), "application/json"); + String response = GSON.toJson(stats); + sendResponse(exchange, 200, response.getBytes(StandardCharsets.UTF_8), "application/json"); } catch (Exception e) { - PlayerTimeMod.LOGGER.error("[在线时间] 无法获得统计数据", e); + PlayerTimeMod.LOGGER.error("[PlayerTime] Failed to get stats data", e); sendResponse(exchange, 500, "Internal Server Error"); } }); + // 语言文件内容 + server.createContext("/api/lang", 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 { + 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(); + } catch (Exception e) { + return null; + } + }) + .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"); + return; + } + PlayerTimeMod.LOGGER.warn("[PlayerTime] Configured language file ({}.json) not found, using default (en_us.json).", PlayerTimeMod.getConfig().getLanguage()); + } + + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + byte[] data = new byte[1024]; + int nRead; + while ((nRead = is.read(data, 0, data.length)) != -1) { + buffer.write(data, 0, nRead); + } + buffer.flush(); + is.close(); + + sendResponse(exchange, 200, buffer.toByteArray(), "application/json"); + PlayerTimeMod.LOGGER.debug("[PlayerTime] Served language file: {}", resourcePath); + + } catch (IOException e) { + PlayerTimeMod.LOGGER.error("[PlayerTime] Failed to read language file", e); + sendResponse(exchange, 500, "Error reading language file"); + } catch (Exception e) { + PlayerTimeMod.LOGGER.error("[PlayerTime] An unknown error occurred while processing language request", e); + sendResponse(exchange, 500, "Internal Server Error"); + } + }); + + // 静态文件服务 server.createContext("/", exchange -> { try { - String path = exchange.getRequestURI().getPath(); - if (path.equals("/")) path = "/index.html"; + 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; + } + + + String finalResourcePath = resourcePath; + InputStream is = FabricLoader.getInstance().getModContainer("playertime") + .flatMap(container -> container.findPath(finalResourcePath)) + .map(p -> { + try { + return p.toUri().toURL().openStream(); + } catch (Exception e) { + return null; + } + }) + .orElse(null); - // 从资源目录加载文件 - String resourcePath = "assets/playertime/web" + path; - InputStream is = getClass().getClassLoader().getResourceAsStream(resourcePath); if (is == null) { + PlayerTimeMod.LOGGER.warn("[PlayerTime] Static resource not found: {}", resourcePath); sendResponse(exchange, 404, "Not Found"); return; } - // 确定内容类型 - String extension = path.substring(path.lastIndexOf('.') + 1); - String contentType = MIME_TYPES.getOrDefault(extension, "text/plain"); + // 确定内容类型,一层保险 + String extension = ""; + int dotIndex = resourceFileName.lastIndexOf('.'); + if (dotIndex > 0 && dotIndex < resourceFileName.length() - 1) { + extension = resourceFileName.substring(dotIndex + 1).toLowerCase(); + } + String contentType = MIME_TYPES.getOrDefault(extension, "application/octet-stream"); // 读取文件内容 ByteArrayOutputStream buffer = new ByteArrayOutputStream(); @@ -93,22 +206,23 @@ public class WebServer { buffer.write(data, 0, nRead); } buffer.flush(); + is.close(); sendResponse(exchange, 200, buffer.toByteArray(), contentType); + PlayerTimeMod.LOGGER.debug("[PlayerTime] Served static file: {}", resourcePath); + } catch (Exception e) { - PlayerTimeMod.LOGGER.error("[在线时间] 无法提供资源", e); + PlayerTimeMod.LOGGER.error("[PlayerTime] Failed to serve static resource", e); sendResponse(exchange, 500, "Internal Server Error"); } }); + // 没啥用了 server.createContext("/api/widget-data", 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"); - + handleCors(exchange); if ("OPTIONS".equals(exchange.getRequestMethod())) { - exchange.sendResponseHeaders(204, -1); // 204 No Content + exchange.sendResponseHeaders(204, -1); return; } @@ -127,7 +241,6 @@ public class WebServer { response.addProperty("onlineCount", playerManager.getCurrentPlayerCount()); JsonArray whitelistPlayers = new JsonArray(); - Map playerTimeMap = new HashMap<>(); Set whitelistUuids = new HashSet<>(); for (String name : playerManager.getWhitelist().getNames()) { @@ -145,7 +258,6 @@ public class WebServer { playerJson.addProperty("name", player.getName().getString()); playerJson.addProperty("time", PlayerTimeTracker.formatTime(stats.totalTime)); whitelistPlayers.add(playerJson); - playerTimeMap.put(player.getName().getString(), stats.totalTime); } } } @@ -159,7 +271,7 @@ public class WebServer { .forEach(entry -> { JsonObject playerJson = new JsonObject(); playerJson.addProperty("name", timeTracker.getPlayerName(entry.getKey())); - playerJson.addProperty("time", PlayerTimeTracker.formatTime(entry.getValue().totalTime)); + playerJson.addProperty("time", PlayerTimeTracker.formatTime(entry.getValue().totalTime)); // formatTime doesn't need localization topPlayers.add(playerJson); }); @@ -168,17 +280,14 @@ public class WebServer { sendResponse(exchange, 200, GSON.toJson(response).getBytes(StandardCharsets.UTF_8), "application/json"); } catch (Exception e) { - PlayerTimeMod.LOGGER.error("[在线时间] 无法获得统计数据", e); + PlayerTimeMod.LOGGER.error("[PlayerTime] Failed to get widget data", e); sendResponse(exchange, 500, "Internal Server Error"); } }); // 在线玩家列表 server.createContext("/api/online-players", exchange -> { - exchange.getResponseHeaders().add("Access-Control-Allow-Origin", "*"); - exchange.getResponseHeaders().add("Access-Control-Allow-Methods", "GET, OPTIONS"); - exchange.getResponseHeaders().add("Access-Control-Allow-Headers", "Content-Type"); - + handleCors(exchange); if ("OPTIONS".equals(exchange.getRequestMethod())) { exchange.sendResponseHeaders(204, -1); return; @@ -223,17 +332,14 @@ public class WebServer { sendResponse(exchange, 200, GSON.toJson(response).getBytes(StandardCharsets.UTF_8), "application/json"); } catch (Exception e) { - PlayerTimeMod.LOGGER.error("[在线时间] 无法获取在线玩家列表", e); + PlayerTimeMod.LOGGER.error("[PlayerTime] Failed to get online players list", e); sendResponse(exchange, 500, "Internal Server Error"); } }); // 玩家计数 server.createContext("/api/player-count", exchange -> { - exchange.getResponseHeaders().add("Access-Control-Allow-Origin", "*"); - exchange.getResponseHeaders().add("Access-Control-Allow-Methods", "GET, OPTIONS"); - exchange.getResponseHeaders().add("Access-Control-Allow-Headers", "Content-Type"); - + handleCors(exchange); if ("OPTIONS".equals(exchange.getRequestMethod())) { exchange.sendResponseHeaders(204, -1); return; @@ -274,17 +380,14 @@ public class WebServer { sendResponse(exchange, 200, GSON.toJson(response).getBytes(StandardCharsets.UTF_8), "application/json"); } catch (Exception e) { - PlayerTimeMod.LOGGER.error("[在线时间] 无法获取玩家计数", e); + PlayerTimeMod.LOGGER.error("[PlayerTime] Failed to get player count", e); sendResponse(exchange, 500, "Internal Server Error"); } }); - // 白名单玩家 + // 白名单玩家(还有用吗?) server.createContext("/api/whitelist", exchange -> { - exchange.getResponseHeaders().add("Access-Control-Allow-Origin", "*"); - exchange.getResponseHeaders().add("Access-Control-Allow-Methods", "GET, OPTIONS"); - exchange.getResponseHeaders().add("Access-Control-Allow-Headers", "Content-Type"); - + handleCors(exchange); if ("OPTIONS".equals(exchange.getRequestMethod())) { exchange.sendResponseHeaders(204, -1); return; @@ -320,17 +423,14 @@ public class WebServer { sendResponse(exchange, 200, GSON.toJson(whitelist).getBytes(StandardCharsets.UTF_8), "application/json"); } catch (Exception e) { - PlayerTimeMod.LOGGER.error("[在线时间] 无法获取白名单", e); + PlayerTimeMod.LOGGER.error("[PlayerTime] Failed to get whitelist", e); sendResponse(exchange, 500, "Internal Server Error"); } }); // 服务器状态 server.createContext("/api/server-status", exchange -> { - exchange.getResponseHeaders().add("Access-Control-Allow-Origin", "*"); - exchange.getResponseHeaders().add("Access-Control-Allow-Methods", "GET, OPTIONS"); - exchange.getResponseHeaders().add("Access-Control-Allow-Headers", "Content-Type"); - + handleCors(exchange); if ("OPTIONS".equals(exchange.getRequestMethod())) { exchange.sendResponseHeaders(204, -1); return; @@ -362,7 +462,7 @@ public class WebServer { long uptime = ManagementFactory.getRuntimeMXBean().getUptime(); status.addProperty("uptime", uptime); - status.addProperty("uptime_formatted", formatUptime(uptime)); + status.addProperty("uptime_formatted", formatUptime(uptime)); // formatUptime doesn't need localization File diskPartition = new File("."); long totalSpace = diskPartition.getTotalSpace(); @@ -400,29 +500,62 @@ public class WebServer { sendResponse(exchange, 200, GSON.toJson(status).getBytes(StandardCharsets.UTF_8), "application/json"); } catch (Exception e) { - PlayerTimeMod.LOGGER.error("[在线时间] 无法获取服务器状态", e); + PlayerTimeMod.LOGGER.error("[PlayerTime] Failed to get server status", e); + sendResponse(exchange, 500, "Internal Server Error"); + } + }); + + // 原始数据文件 + server.createContext("/api/playerdata", exchange -> { + handleCors(exchange); + if ("OPTIONS".equals(exchange.getRequestMethod())) { + exchange.sendResponseHeaders(204, -1); // 204 No Content + return; + } + + if (!"GET".equals(exchange.getRequestMethod())) { + sendResponse(exchange, 405, "Method Not Allowed"); + return; + } + + try { + Path dataFile = timeTracker.getDataFile(); // 从PlayerTimeTracker获取文件路径 + + if (!Files.exists(dataFile)) { + PlayerTimeMod.LOGGER.warn("[PlayerTime] Player data file not found: {}", dataFile); + sendResponse(exchange, 404, "Data file not found"); + return; + } + + byte[] fileContent = Files.readAllBytes(dataFile); + + sendResponse(exchange, 200, fileContent, "application/json"); + PlayerTimeMod.LOGGER.debug("[PlayerTime] Successfully served player data file {}", dataFile); + + } catch (IOException e) { + PlayerTimeMod.LOGGER.error("[PlayerTime] Error reading player data file", e); + sendResponse(exchange, 500, "Error reading data file"); + } catch (Exception e) { + PlayerTimeMod.LOGGER.error("[PlayerTime] An unknown error occurred while processing player data file request", e); sendResponse(exchange, 500, "Internal Server Error"); } }); - - - - 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"); } private void sendResponse(HttpExchange exchange, int code, byte[] response, String contentType) throws IOException { - exchange.getResponseHeaders().set("Access-Control-Allow-Origin", "*"); - exchange.getResponseHeaders().set("Access-Control-Allow-Methods", "GET, OPTIONS"); - exchange.getResponseHeaders().set("Access-Control-Allow-Headers", "Content-Type"); - - exchange.getResponseHeaders().set("Content-Type", contentType); exchange.sendResponseHeaders(code, response.length); try (OutputStream os = exchange.getResponseBody()) { @@ -440,41 +573,6 @@ public class WebServer { } - // 以前ai留下的烂方法,大概没用了,注释也是ai的 - - private String generatePlayerList(Map stats) { - if (stats.isEmpty()) return "

暂无玩家数据

"; - - StringBuilder sb = new StringBuilder("
    "); - stats.keySet().stream().limit(5).forEach(player -> { - sb.append("
  • • ").append(player).append("
  • "); - }); - if (stats.size() > 5) { - sb.append("
  • ... 等 ").append(stats.size() - 5).append(" 位玩家
  • "); - } - sb.append("
"); - return sb.toString(); - } - - // 辅助方法:将"Xh Ym"格式的时间转换为秒数 - private long parseTimeToSeconds(String timeStr) { - String[] parts = timeStr.split(" "); - int hours = Integer.parseInt(parts[0].replace("h", "")); - int minutes = parts.length > 1 ? Integer.parseInt(parts[1].replace("m", "")) : 0; - return hours * 3600L + minutes * 60L; - } - - // 读取时间数据文件 - private JsonObject readTimeData(Path dataFile) throws IOException { - if (!Files.exists(dataFile)) { - return new JsonObject(); - } - - try (Reader reader = Files.newBufferedReader(dataFile)) { - return JsonParser.parseReader(reader).getAsJsonObject(); - } - } - private String formatUptime(long millis) { long seconds = millis / 1000; long days = seconds / 86400; @@ -486,17 +584,4 @@ public class WebServer { return String.format("%d天 %02d小时 %02d分钟 %02d秒", days, hours, minutes, seconds); } - - // 获取玩家总时长 - private long getPlayerTotalTime(String uuid, JsonObject timeData) { - if (!timeData.has(uuid)) { - return 0; - } - - JsonObject playerData = timeData.getAsJsonObject(uuid); - if (playerData.has("totalTime")) { - return playerData.get("totalTime").getAsLong(); - } - return 0; - } -} \ No newline at end of file +} diff --git a/src/main/resources/assets/playertime/lang/en_us.json b/src/main/resources/assets/playertime/lang/en_us.json new file mode 100644 index 0000000..282aa2a --- /dev/null +++ b/src/main/resources/assets/playertime/lang/en_us.json @@ -0,0 +1,46 @@ +{ + "playertime.command.title": "§6===== Player Online Time (Page {0}/{1}) =====", + "playertime.command.prev_page": "§a[Previous Page]", + "playertime.command.next_page": "§a[Next Page]", + "playertime.command.total_players": "§7Total {0} players", + "playertime.stats.format": "Total: {0} | 30 Days: {1} | 7 Days: {2}", + "playertime.stats.total": "Total Time", + "playertime.stats.30day": "Last 30 Days", + "playertime.stats.7day": "Last 7 Days", + + "playertime.web.title": "Player online and status statistics", + "playertime.web.warning": "Data tracking starts from the mod installation time and does not include any data before installation.", + "playertime.web.status.online_players": "Online Players", + "playertime.web.status.total": "Total", + "playertime.web.status.players": "Players", + "playertime.web.status.non_players": "Non-Players", + "playertime.web.status.online_list": "Online Player List", + "playertime.web.status.online_list.players": "Players", + "playertime.web.status.online_list.non_players": "Non-Players", + "playertime.web.status.online_list.empty_players": "No players online", + "playertime.web.status.online_list.empty_non_players": "No non-players online", + + "playertime.web.server_status.title": "Server Status", + "playertime.web.server_status.memory_usage": "Memory Usage", + "playertime.web.server_status.memory_used": "Used", + "playertime.web.server_status.memory_free": "Free", + "playertime.web.server_status.memory_percent": "Usage", + "playertime.web.server_status.performance": "Real-time Performance", + "playertime.web.server_status.uptime": "Server Uptime", + + "playertime.web.stats_table.title": "Player Online Time (Whitelist)", + "playertime.web.stats_table.info": "Only tracks and displays whitelisted players.", + "playertime.web.stats_table.header.player": "Player", + "playertime.web.stats_table.header.total_time": "Total Time", + "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.refresh_button": "Refresh", + + "playertime.web.error.load_failed": "Failed to load data, check console" +} diff --git a/src/main/resources/assets/playertime/lang/zh_cn.json b/src/main/resources/assets/playertime/lang/zh_cn.json new file mode 100644 index 0000000..7168f7c --- /dev/null +++ b/src/main/resources/assets/playertime/lang/zh_cn.json @@ -0,0 +1,46 @@ +{ + "playertime.command.title": "§6===== 玩家在线时长 (第 {0}/{1} 页) =====", + "playertime.command.prev_page": "§a[上一页]", + "playertime.command.next_page": "§a[下一页]", + "playertime.command.total_players": "§7共 {0} 位玩家", + "playertime.stats.format": "总时长: {0} | 30天: {1} | 7天: {2}", + "playertime.stats.total": "总时长", + "playertime.stats.30day": "30天", + "playertime.stats.7day": "7天", + + "playertime.web.title": "玩家在线及状态统计", + "playertime.web.warning": "数据统计时间开始于此MOD安装时间,不包含安装之前的所有数据", + "playertime.web.status.online_players": "在线玩家", + "playertime.web.status.total": "总数", + "playertime.web.status.players": "玩家", + "playertime.web.status.non_players": "假人", + "playertime.web.status.online_list": "在线玩家列表", + "playertime.web.status.online_list.players": "玩家", + "playertime.web.status.online_list.non_players": "假人", + "playertime.web.status.online_list.empty_players": "暂无玩家在线", + "playertime.web.status.online_list.empty_non_players": "暂无假人在线", + + "playertime.web.server_status.title": "服务器状态", + "playertime.web.server_status.memory_usage": "内存使用", + "playertime.web.server_status.memory_used": "已用", + "playertime.web.server_status.memory_free": "可用", + "playertime.web.server_status.memory_percent": "使用率", + "playertime.web.server_status.performance": "实时性能", + "playertime.web.server_status.uptime": "服务器运行时间", + + "playertime.web.stats_table.title": "玩家在线时长 (白名单)", + "playertime.web.stats_table.info": "仅跟踪和显示列入白名单的玩家", + "playertime.web.stats_table.header.player": "玩家", + "playertime.web.stats_table.header.total_time": "总计时间", + "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.refresh_button": "刷新数据", + + "playertime.web.error.load_failed": "加载数据失败,请检查控制台" +} diff --git a/src/main/resources/assets/playertime/web/index.html b/src/main/resources/assets/playertime/web/index.html index 28fd94b..99b5cd1 100644 --- a/src/main/resources/assets/playertime/web/index.html +++ b/src/main/resources/assets/playertime/web/index.html @@ -3,7 +3,7 @@ - [在线时间] 玩家在线时间及服务器状态 + 玩家在线及状态统计 @@ -12,74 +12,74 @@
-

玩家在线时间统计

+

玩家在线时间统计

-
数据统计时间开始于此MOD安装时间,不包含安装之前的所有数据
+
数据统计时间开始于此MOD安装时间,不包含安装之前的所有数据

- +
-

在线玩家

+

在线玩家

- 总数: + 总数: 0
- 玩家: + 玩家: 0
- 假人: + 假人: 0
-

在线玩家列表

+

在线玩家列表

-

玩家

+

玩家

    -

    假人

    +

    假人

      - +
      -

      服务器状态

      +

      服务器状态

      -

      内存使用

      +

      内存使用

      -
      已用: 0 MB
      -
      可用: 0 MB
      -
      使用率: 0%
      +
      已用: 0 MB
      +
      可用: 0 MB
      +
      使用率: 0%
      -

      实时性能

      +

      实时性能

      - TPS: + TPS: 0.0
      - MSPT: + MSPT: 0.0
      @@ -91,26 +91,28 @@
      +
      +

      服务器运行时间

      +

      加载中...

      +
      - -

      玩家在线时长 (白名单)

      + +

      玩家在线时长 (白名单)

      -

      仅跟踪和显示列入白名单的玩家

      +

      仅跟踪和显示列入白名单的玩家

      - - - - - - + + + + diff --git a/src/main/resources/assets/playertime/web/js/app.js b/src/main/resources/assets/playertime/web/js/app.js index cdc401a..1b74570 100644 --- a/src/main/resources/assets/playertime/web/js/app.js +++ b/src/main/resources/assets/playertime/web/js/app.js @@ -4,12 +4,14 @@ document.addEventListener('DOMContentLoaded', function() { const msptHistory = Array(30).fill(50); let memoryChart = null; let performanceChart = null; + let lang = {}; // 新增:存储语言文件内容 // 2. 获取DOM元素(带安全检查) const elements = { refreshBtn: document.getElementById('refresh-btn'), themeToggle: document.getElementById('theme-toggle'), - statsTable: document.getElementById('stats-table')?.getElementsByTagName('tbody')[0], + statsTableBody: document.getElementById('stats-table')?.getElementsByTagName('tbody')[0], // Changed ID for clarity + statsTableHeader: document.getElementById('stats-table')?.getElementsByTagName('thead')[0], // Added for header localization whitelistPlayers: document.getElementById('whitelist-players'), nonWhitelistPlayers: document.getElementById('non-whitelist-players'), totalCount: document.getElementById('total-count'), @@ -18,7 +20,7 @@ document.addEventListener('DOMContentLoaded', function() { memoryUsed: document.getElementById('memory-used'), memoryFree: document.getElementById('memory-free'), memoryPercent: document.getElementById('memory-percent'), - avgTick: document.getElementById('avg-tick'), + avgTick: document.getElementById('avg-tick'), // This element seems unused in updateServerStatus now, can remove if not needed uptime: document.getElementById('uptime'), tpsValue: document.getElementById('tps-value'), msptValue: document.getElementById('mspt-value') @@ -28,8 +30,18 @@ document.addEventListener('DOMContentLoaded', function() { const savedTheme = localStorage.getItem('theme') || 'light'; document.documentElement.setAttribute('data-theme', savedTheme); - // 4. 初始化图表(必须在其他函数之前) - initCharts(); + // 4. 初始加载语言文件,然后加载其他数据并初始化图表 + loadLanguage().then(() => { + translatePage(); // 本地化页面静态文本 + initCharts(); // 初始化图表(需要语言文本) + loadAllData(); // 加载动态数据 + // 7. 设置定时刷新(10秒) + const refreshInterval = setInterval(loadAllData, 10000); + }).catch(error => { + console.error('Failed to load language or initial data:', error); + showError('Failed to load language or initial data.'); // Fallback error message + }); + // 5. 事件监听器 if (elements.themeToggle) { @@ -40,13 +52,57 @@ document.addEventListener('DOMContentLoaded', function() { elements.refreshBtn.addEventListener('click', handleRefresh); } - // 6. 初始加载数据 - loadAllData(); - - // 7. 设置定时刷新(10秒) - const refreshInterval = setInterval(loadAllData, 10000); - /*** 功能函数 ***/ + + // 新增:加载语言文件 + async function loadLanguage() { + try { + const response = await fetch('/api/lang'); + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + lang = await response.json(); + console.log('Language file loaded.'); + } catch (error) { + console.error('Failed to load language file:', error); + // Fallback to English keys if language file fails to load + lang = {}; + throw error; // Propagate error to stop further loading if language is critical + } + } + + // 新增:根据data-lang-key属性本地化页面元素 + function translatePage() { + document.querySelectorAll('[data-lang-key]').forEach(element => { + const key = element.getAttribute('data-lang-key'); + if (lang[key]) { + // Special handling for title tag + if (element.tagName === 'TITLE') { + document.title = lang[key]; + } else { + element.textContent = lang[key]; + } + } else { + console.warn(`Missing translation key: ${key}`); + } + }); + } + + // 新增:获取本地化字符串 + function getLangString(key, ...args) { + const pattern = lang[key] || key; // Use key as fallback + try { + // Simple placeholder replacement {0}, {1}, etc. + let result = pattern; + args.forEach((arg, index) => { + result = result.replace(new RegExp('\\{' + index + '\\}', 'g'), arg); + }); + return result; + } catch (e) { + console.error(`Failed to format string for key: ${key}`, e); + return pattern; // Return unformatted pattern on error + } + } + + function initCharts() { // 内存图表 const memoryCtx = document.getElementById('memory-chart')?.getContext('2d'); @@ -54,7 +110,8 @@ document.addEventListener('DOMContentLoaded', function() { memoryChart = new Chart(memoryCtx, { type: 'doughnut', data: { - labels: ['已使用', '未使用'], + // Use localized labels + labels: [getLangString('playertime.web.chart.memory_used'), getLangString('playertime.web.chart.memory_free')], datasets: [{ data: [0, 100], backgroundColor: ['#4361ee', '#e9ecef'] @@ -62,7 +119,12 @@ document.addEventListener('DOMContentLoaded', function() { }, options: { responsive: true, - maintainAspectRatio: false + maintainAspectRatio: false, + plugins: { + legend: { + position: 'top', + } + } } }); } @@ -76,43 +138,99 @@ document.addEventListener('DOMContentLoaded', function() { labels: Array(30).fill(''), datasets: [ { - label: 'TPS', + // Use localized labels + label: getLangString('playertime.web.chart.tps'), data: tpsHistory, borderColor: '#4CAF50', - tension: 0.1, + backgroundColor: 'rgba(76, 175, 80, 0.1)', + borderWidth: 2, + pointRadius: 3, + pointHoverRadius: 5, + tension: 0.3, yAxisID: 'y' }, { - label: 'MSPT', + // Use localized labels + label: getLangString('playertime.web.chart.mspt'), data: msptHistory, borderColor: '#FF5722', - tension: 0.1, + backgroundColor: 'rgba(255, 87, 34, 0.1)', + borderWidth: 2, + pointRadius: 3, + pointHoverRadius: 5, + tension: 0.3, yAxisID: 'y1' } ] }, options: { responsive: true, - interaction: { - mode: 'index', - intersect: false + maintainAspectRatio: false, + animation: { + duration: 500 + }, + plugins: { + legend: { + position: 'top', + labels: { + boxWidth: 12, + padding: 20, + font: { + size: 12 + } + } + }, + tooltip: { + mode: 'index', + intersect: false, + bodySpacing: 8 + } }, scales: { + x: { + grid: { + display: false + }, + ticks: { + maxRotation: 0, + autoSkip: true, + maxTicksLimit: 10 + } + }, y: { type: 'linear', display: true, position: 'left', - title: { display: true, text: 'TPS' }, + title: { + display: true, + // Use localized labels + text: getLangString('playertime.web.chart.tps'), + font: { + weight: 'bold' + } + }, min: 0, - max: 20 + max: 20, + grid: { + color: 'rgba(0, 0, 0, 0.05)' + } }, y1: { type: 'linear', display: true, position: 'right', - title: { display: true, text: 'MSPT (ms)' }, + title: { + display: true, + // Use localized labels + text: getLangString('playertime.web.chart.mspt'), + font: { + weight: 'bold' + } + }, min: 0, - grid: { drawOnChartArea: false } + grid: { + drawOnChartArea: false + } } } } @@ -128,67 +246,126 @@ document.addEventListener('DOMContentLoaded', function() { } function handleRefresh() { + if (this.classList.contains('loading')) return; // Prevent multiple clicks this.classList.add('loading'); - loadAllData(); - setTimeout(() => { - this.classList.remove('loading'); - }, 1000); + loadAllData().finally(() => { // Use finally to ensure loading class is removed + setTimeout(() => { // Add a small delay for visual feedback + this.classList.remove('loading'); + }, 500); + }); } async function loadAllData() { try { - await Promise.all([ - fetchData('/api/stats', updateTable), - fetchData('/api/online-players', updateOnlinePlayers), - fetchData('/api/player-count', updatePlayerCounts), - fetchData('/api/server-status', updateServerStatus) + // Fetch data concurrently + const [statsData, onlinePlayersData, playerCountsData, serverStatusData] = await Promise.all([ + fetchData('/api/stats'), + fetchData('/api/online-players'), + fetchData('/api/player-count'), + fetchData('/api/server-status') ]); + + // Update UI with fetched data + updateTable(statsData); + updateOnlinePlayers(onlinePlayersData); + updatePlayerCounts(playerCountsData); + updateServerStatus(serverStatusData); + } catch (error) { console.error('加载数据出错:', error); - showError('加载数据失败,请检查控制台'); + showError(getLangString('playertime.web.error.load_failed')); // Use localized error message } } - async function fetchData(url, callback) { - try { - const response = await fetch(url); - if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); - const data = await response.json(); - callback(data); - } catch (error) { - console.error(`获取 ${url} 数据失败:`, error); - throw error; + async function fetchData(url) { + const response = await fetch(url); + if (!response.ok) { + // Attempt to read error body if available + const errorBody = await response.text().catch(() => 'Unknown Error'); + throw new Error(`HTTP error! status: ${response.status}, URL: ${url}, Body: ${errorBody}`); } + return response.json(); } function updateTable(data) { - if (!elements.statsTable) return; + if (!elements.statsTableBody || !elements.statsTableHeader) return; + + elements.statsTableBody.innerHTML = ''; // Clear existing rows + + // Update table headers using localization + const headers = elements.statsTableHeader.querySelectorAll('th[data-lang-key]'); + headers.forEach(th => { + const key = th.getAttribute('data-lang-key'); + if (lang[key]) { + th.textContent = lang[key]; + } + }); + - elements.statsTable.innerHTML = ''; const sortedPlayers = Object.entries(data) .map(([name, statString]) => { + // Parse the localized stat string const stats = {}; + // Split by " | " first statString.split(" | ").forEach(part => { - const [label, value] = part.split(": "); - stats[label.trim()] = value; + // Find the first colon to split label and value + 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 { + // Handle cases without colon if necessary, or log a warning + console.warn(`Could not parse stat part: ${part}`); + } }); - return { name, stats }; + + // Map localized labels back to internal keys for sorting + const internalStats = {}; + // This mapping assumes the order in the format string is consistent + 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')); + // Fallback to using the raw statString if parsing fails + 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["总时长"]) - parseTimeToSeconds(a.stats["总时长"])); + .sort((a, b) => parseTimeToSeconds(b.stats.totalTime) - parseTimeToSeconds(a.stats.totalTime)); + + if (sortedPlayers.length === 0) { + const row = elements.statsTableBody.insertRow(); + const cell = row.insertCell(0); + cell.colSpan = 4; // Span across all columns + cell.textContent = getLangString('playertime.web.stats_table.empty'); // Use localized empty message + cell.style.textAlign = 'center'; + cell.style.fontStyle = 'italic'; + return; + } + sortedPlayers.forEach(player => { - const row = elements.statsTable.insertRow(); + const row = elements.statsTableBody.insertRow(); row.insertCell(0).textContent = player.name; - row.insertCell(1).textContent = player.stats["总时长"]; - row.insertCell(2).textContent = player.stats["30天"]; - row.insertCell(3).textContent = player.stats["7天"]; + row.insertCell(1).textContent = player.stats.totalTime; + row.insertCell(2).textContent = player.stats['30Days']; + row.insertCell(3).textContent = player.stats['7Days']; }); } function updateOnlinePlayers(data) { if (!elements.whitelistPlayers || !elements.nonWhitelistPlayers) return; - const updateList = (element, players, emptyMessage) => { + const updateList = (element, players, emptyMessageKey) => { element.innerHTML = ''; if (players?.length > 0) { players.forEach(player => { @@ -197,12 +374,13 @@ document.addEventListener('DOMContentLoaded', function() { element.appendChild(li); }); } else { - element.innerHTML = `
    • ${emptyMessage}
    • `; + // Use localized empty message + element.innerHTML = `
    • ${getLangString(emptyMessageKey)}
    • `; } }; - updateList(elements.whitelistPlayers, data.whitelisted, '暂无玩家在线'); - updateList(elements.nonWhitelistPlayers, data.non_whitelisted, '暂无假人在线'); + updateList(elements.whitelistPlayers, data.whitelisted, 'playertime.web.status.online_list.empty_players'); + updateList(elements.nonWhitelistPlayers, data.non_whitelisted, 'playertime.web.status.online_list.empty_non_players'); } function updatePlayerCounts(data) { @@ -212,7 +390,7 @@ document.addEventListener('DOMContentLoaded', function() { } function updateServerStatus(data) { - // 更新内存信息 + // Update memory info if (data.memory) { const usedMB = Math.round(data.memory.used / (1024 * 1024)); const freeMB = Math.round((data.memory.max - data.memory.used) / (1024 * 1024)); @@ -220,6 +398,8 @@ document.addEventListener('DOMContentLoaded', function() { if (memoryChart) { memoryChart.data.datasets[0].data = [usedMB, freeMB]; + // Update chart labels if they weren't set during initCharts (e.g., if lang loaded later) + memoryChart.data.labels = [getLangString('playertime.web.chart.memory_used'), getLangString('playertime.web.chart.memory_free')]; memoryChart.update(); } @@ -228,73 +408,67 @@ document.addEventListener('DOMContentLoaded', function() { if (elements.memoryPercent) elements.memoryPercent.textContent = percent; } - // 更新性能信息 + // Update performance info if (data.server) { - // 计算TPS (限制在0-20之间) + // Calculate TPS (limit between 0-20) const tps = Math.min(20, 1000 / (data.server.average_tick_time_ms || 50)).toFixed(1); const mspt = data.server.average_tick_time_ms.toFixed(1); - // 更新显示 + // Update display if (elements.tpsValue) elements.tpsValue.textContent = tps; if (elements.msptValue) elements.msptValue.textContent = mspt; - // 更新图表数据 + // Update chart data if (performanceChart) { - // 更新历史数据 + // Update history data tpsHistory.shift(); tpsHistory.push(parseFloat(tps)); msptHistory.shift(); msptHistory.push(parseFloat(mspt)); - // 只更新数据不重新创建图表 + // Update chart labels if they weren't set during initCharts + 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'); + + + // Only update data (do not recreate chart) performanceChart.data.datasets[0].data = tpsHistory; performanceChart.data.datasets[1].data = msptHistory; - performanceChart.update(); + + // Smooth update + performanceChart.update('none'); + + // Set color based on value + setMetricColor(elements.tpsValue, tps, 15, 10, false); // TPS: higher is better + setMetricColor(elements.msptValue, mspt, 50, 100, true); // MSPT: lower is better } - if (elements.avgTick) elements.avgTick.textContent = data.server.average_tick_time_ms?.toFixed(2) || '0'; + // if (elements.avgTick) elements.avgTick.textContent = data.server.average_tick_time_ms?.toFixed(2) || '0'; // This element seems unused + } - // 更新性能图表 - if (performanceChart && data.server) { - // 计算TPS和MSPT - const tps = Math.min(20, 1000 / (data.server.average_tick_time_ms || 50)).toFixed(1); - const mspt = data.server.average_tick_time_ms.toFixed(1); - - // 更新历史数据 - tpsHistory.shift(); - tpsHistory.push(parseFloat(tps)); - msptHistory.shift(); - msptHistory.push(parseFloat(mspt)); - - // 只更新数据(不重新创建图表) - performanceChart.data.datasets[0].data = tpsHistory; - performanceChart.data.datasets[1].data = msptHistory; - - // 平滑更新 - performanceChart.update('none'); - - // 更新数值显示 - if (elements.tpsValue) elements.tpsValue.textContent = tps; - if (elements.msptValue) elements.msptValue.textContent = mspt; - - // 根据数值设置颜色 - setMetricColor(elements.tpsValue, tps, 15, 10); - setMetricColor(elements.msptValue, mspt, 50, 100, true); - } - - if (elements.uptime) elements.uptime.textContent = data.uptime_formatted || '0'; + if (elements.uptime) elements.uptime.textContent = data.uptime_formatted || '0'; // uptime_formatted is already localized by server } function parseTimeToSeconds(timeStr) { if (!timeStr) return 0; - return timeStr.split(' ').reduce((total, part) => { - if (part.includes('h')) return total + parseInt(part.replace('h', '')) * 3600; - if (part.includes('m')) return total + parseInt(part.replace('m', '')) * 60; - return total; - }, 0); + // Ensure parsing works even if labels are present, by only looking for h and m + const parts = timeStr.match(/(\d+h)?\s*(\d+m)?/); + let seconds = 0; + if (parts) { + if (parts[1]) { // hours part + seconds += parseInt(parts[1].replace('h', '')) * 3600; + } + if (parts[2]) { // minutes part + seconds += parseInt(parts[2].replace('m', '')) * 60; + } + } + return seconds; } + function showError(message) { const errorEl = document.createElement('div'); errorEl.className = 'error-message'; @@ -306,123 +480,35 @@ document.addEventListener('DOMContentLoaded', function() { setTimeout(() => { errorEl.classList.remove('show'); setTimeout(() => document.body.removeChild(errorEl), 300); - }, 5000); - }, 10); + }, 5000); // Display for 5 seconds + }, 10); // Small delay to allow CSS transition } - function initPerformanceChart() { - const perfCtx = document.getElementById('performance-chart')?.getContext('2d'); - if (!perfCtx) return null; - - return new Chart(perfCtx, { - type: 'line', - data: { - labels: Array(30).fill(''), - datasets: [ - { - label: 'TPS', - data: tpsHistory, - borderColor: '#4CAF50', - backgroundColor: 'rgba(76, 175, 80, 0.1)', - borderWidth: 2, - pointRadius: 3, - pointHoverRadius: 5, - tension: 0.3, - yAxisID: 'y' - }, - { - label: 'MSPT', - data: msptHistory, - borderColor: '#FF5722', - backgroundColor: 'rgba(255, 87, 34, 0.1)', - borderWidth: 2, - pointRadius: 3, - pointHoverRadius: 5, - tension: 0.3, - yAxisID: 'y1' - } - ] - }, - options: { - responsive: true, - maintainAspectRatio: false, - animation: { - duration: 500 - }, - plugins: { - legend: { - position: 'top', - labels: { - boxWidth: 12, - padding: 20, - font: { - size: 12 - } - } - }, - tooltip: { - mode: 'index', - intersect: false, - bodySpacing: 8 - } - }, - scales: { - x: { - grid: { - display: false - }, - ticks: { - maxRotation: 0, - autoSkip: true, - maxTicksLimit: 10 - } - }, - y: { - type: 'linear', - display: true, - position: 'left', - title: { - display: true, - text: 'TPS', - font: { - weight: 'bold' - } - }, - min: 0, - max: 20, - grid: { - color: 'rgba(0, 0, 0, 0.05)' - } - }, - y1: { - type: 'linear', - display: true, - position: 'right', - title: { - display: true, - text: 'MSPT (ms)', - font: { - weight: 'bold' - } - }, - min: 0, - grid: { - drawOnChartArea: false - } - } - } - } - }); - } - - function setMetricColor(element, value, warnThreshold, dangerThreshold, reverse = false) { + // Helper function to set color based on metric value and thresholds + function setMetricColor(element, value, goodThreshold, badThreshold, reverse = false) { if (!element) return; const numValue = parseFloat(value); - element.style.color = - reverse ? - (numValue > dangerThreshold ? '#FF5722' : numValue > warnThreshold ? '#FFC107' : '#4CAF50') : - (numValue < dangerThreshold ? '#FF5722' : numValue < warnThreshold ? '#FFC107' : '#4CAF50'); + let color = ''; + + if (reverse) { // For MSPT: lower is better + if (numValue <= goodThreshold) { + color = '#4CAF50'; // Green + } else if (numValue <= badThreshold) { + color = '#FFC107'; // Yellow + } else { + color = '#FF5722'; // Red + } + } else { // For TPS: higher is better + if (numValue >= goodThreshold) { + color = '#4CAF50'; // Green + } else if (numValue >= badThreshold) { + color = '#FFC107'; // Yellow + } else { + color = '#FF5722'; // Red + } + } + element.style.color = color; } }); diff --git a/src/main/resources/config/playertime-default-config.json b/src/main/resources/config/playertime-default-config.json index 7e94180..9963ca1 100644 --- a/src/main/resources/config/playertime-default-config.json +++ b/src/main/resources/config/playertime-default-config.json @@ -1,3 +1,4 @@ { - "webPort": 60048 -} \ No newline at end of file + "webPort": 60048, + "language": "zh_cn" +}
      玩家总计时间最近 30 天最近 7 天玩家总计时间最近 30 天最近 7 天