仓库迁移

This commit is contained in:
BRanulf 2025-04-19 16:39:48 +08:00
commit 5d10f6c2bd
20 changed files with 2024 additions and 0 deletions

15
LICENSE.txt Normal file
View File

@ -0,0 +1,15 @@
MIT许可证
版权所有 (c) 2025 BRanulf
特此免费授予任何获得本软件及相关文档文件(以下简称"软件")副本的人,
无限制地处理本软件的权限,包括但不限于使用、复制、修改、合并、发布、
分发、再许可和/或出售本软件的副本,并允许获得本软件的人这样做,
但须符合以下条件:
上述版权声明和本许可声明应包含在本软件的所有副本或实质性部分中。
本软件按"原样"提供,不提供任何明示或暗示的保证,包括但不限于对适销性、
特定用途适用性和非侵权性的保证。在任何情况下,作者或版权持有人均不对
任何索赔、损害或其他责任负责,无论是在合同、侵权还是其他诉讼中,
由软件或软件的使用或其他交易引起、由软件引起或与之相关的。

5
README.md Normal file
View File

@ -0,0 +1,5 @@
# Player_OnlineTime
一个监视服务器玩家在线时间的mod带web服务器目前并不完善
*震惊该mod的AI含量竟高达60%*

94
build.gradle Normal file
View File

@ -0,0 +1,94 @@
plugins {
id 'fabric-loom' version '1.9.2'
id 'maven-publish'
}
version = project.mod_version
group = project.maven_group
base {
archivesName = project.archives_base_name
}
repositories {
// Add repositories to retrieve artifacts from in here.
// You should only use this when depending on other mods because
// Loom adds the essential maven repositories to download Minecraft and libraries from automatically.
// See https://docs.gradle.org/current/userguide/declaring_repositories.html
// for more information about repositories.
}
dependencies {
// To change the versions see the gradle.properties file
minecraft "com.mojang:minecraft:${project.minecraft_version}"
mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2"
modImplementation "net.fabricmc:fabric-loader:${project.loader_version}"
modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}"
implementation 'com.google.code.gson:gson:2.8.9'
}
processResources {
inputs.property "version", project.version
inputs.property "minecraft_version", project.minecraft_version
inputs.property "loader_version", project.loader_version
filteringCharset "UTF-8"
filesMatching("fabric.mod.json") {
expand "version": project.version,
"minecraft_version": project.minecraft_version,
"loader_version": project.loader_version
}
// from(sourceSets.main.resources.srcDirs) {
// include "assets/**"
// }
}
def targetJavaVersion = 21
tasks.withType(JavaCompile).configureEach {
// ensure that the encoding is set to UTF-8, no matter what the system default is
// this fixes some edge cases with special characters not displaying correctly
// see http://yodaconditions.net/blog/fix-for-java-file-encoding-problems-with-gradle.html
// If Javadoc is generated, this must be specified in that task too.
it.options.encoding = "UTF-8"
if (targetJavaVersion >= 10 || JavaVersion.current().isJava10Compatible()) {
it.options.release.set(targetJavaVersion)
}
}
java {
def javaVersion = JavaVersion.toVersion(targetJavaVersion)
if (JavaVersion.current() < javaVersion) {
toolchain.languageVersion = JavaLanguageVersion.of(targetJavaVersion)
}
// Loom will automatically attach sourcesJar to a RemapSourcesJar task and to the "build" task
// if it is present.
// If you remove this line, sources will not be generated.
withSourcesJar()
}
jar {
from("LICENSE") {
rename { "${it}_${project.archivesBaseName}" }
}
}
// configure the maven publication
publishing {
publications {
create("mavenJava", MavenPublication) {
artifactId = project.archives_base_name
from components.java
}
}
// See https://docs.gradle.org/current/userguide/publishing_maven.html for information on how to set up publishing.
repositories {
// Add repositories to publish to here.
// Notice: This block does NOT have the same function as the block in the top level.
// The repositories here will be used for publishing your artifact, not for
// retrieving dependencies.
}
}

14
gradle.properties Normal file
View File

@ -0,0 +1,14 @@
# Done to increase the memory available to gradle.
org.gradle.jvmargs=-Xmx1G
# Fabric Properties
# check these on https://modmuss50.me/fabric.html
minecraft_version=1.21.4
yarn_mappings=1.21.4+build.8
loader_version=0.16.10
# Mod Properties
mod_version=1.14.514.118
maven_group=org.example1
archives_base_name=playerOnlineTimeTrackerMod
# Dependencies
# check this on https://modmuss50.me/fabric.html
fabric_version=0.119.2+1.21.4

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https://mirrors.aliyun.com/macports/distfiles/gradle/gradle-8.11.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

252
gradlew vendored Normal file
View File

@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
gradlew.bat vendored Normal file
View File

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

9
settings.gradle Normal file
View File

@ -0,0 +1,9 @@
pluginManagement {
repositories {
maven {
name = 'Fabric'
url = 'https://maven.fabricmc.net/'
}
gradlePluginPortal()
}
}

View File

@ -0,0 +1,50 @@
package com.example.playertime;
import com.google.gson.*;
import java.io.*;
import java.nio.file.*;
public class ModConfig {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private final Path configPath;
private int webPort = 60048;
public ModConfig(Path configDir) {
this.configPath = configDir.resolve("playertime-config.json");
loadConfig();
}
private void loadConfig() {
if (!Files.exists(configPath)) {
saveConfig();
return;
}
try (Reader reader = Files.newBufferedReader(configPath)) {
JsonObject json = JsonParser.parseReader(reader).getAsJsonObject();
if (json.has("webPort")) {
webPort = json.get("webPort").getAsInt();
}
} catch (Exception e) {
PlayerTimeMod.LOGGER.error("[在线时间] 加载配置失败", e);
}
}
private void saveConfig() {
JsonObject json = new JsonObject();
json.addProperty("webPort", webPort);
try {
Files.createDirectories(configPath.getParent());
try (Writer writer = Files.newBufferedWriter(configPath)) {
GSON.toJson(json, writer);
}
} catch (Exception e) {
PlayerTimeMod.LOGGER.error("[在线时间] 保存配置失败", e);
}
}
public int getWebPort() {
return webPort;
}
}

View File

@ -0,0 +1,74 @@
package com.example.playertime;
import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.fabricmc.loader.api.FabricLoader;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class PlayerTimeMod implements ModInitializer {
public static final Logger LOGGER = LoggerFactory.getLogger("PlayerTimeTracker");
private static PlayerTimeTracker timeTracker;
private static WebServer webServer;
private static ModConfig config;
@Override
public void onInitialize() {
config = new ModConfig(FabricLoader.getInstance().getConfigDir());
try {
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() + " 启动");
} catch (Exception e) {
LOGGER.error("[在线时间] 无法启动Web服务器", e);
}
});
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> {
if (timeTracker != null) {
timeTracker.onPlayerJoin(handler.player);
}
});
ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> {
if (timeTracker != null) {
timeTracker.onPlayerLeave(handler.player);
}
});
ServerLifecycleEvents.SERVER_STOPPING.register(server -> {
LOGGER.info("[在线时间] 服务器停止 - 保存数据");
if (webServer != null) {
webServer.stop();
}
if (timeTracker != null) {
timeTracker.saveAll();
}
});
} catch (Exception e) {
LOGGER.error("[在线时间] Mod 初始化失败!", e);
throw new RuntimeException("[在线时间] Mod 初始化失败", e);
}
}
public static ModConfig getConfig() {
return config;
}
public static PlayerTimeTracker getTimeTracker() {
return timeTracker;
}
}

View File

@ -0,0 +1,242 @@
package com.example.playertime;
import com.google.gson.*;
import com.mojang.authlib.GameProfile;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerPlayerEntity;
import java.io.*;
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;
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<>();
public PlayerTimeTracker(MinecraftServer server) {
this.server = server;
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();
saveAsync(player.getUuid());
}
public void onPlayerLeave(ServerPlayerEntity player) {
PlayerTimeData data = playerData.get(player.getUuid());
if (data != null) {
long now = Instant.now().getEpochSecond();
long sessionTime = now - data.lastLogin;
data.totalTime += sessionTime;
// 维护30天滚动窗口
data.rolling30Days.addPlayTime(now, sessionTime);
data.rolling7Days.addPlayTime(now, sessionTime);
data.lastLogin = 0;
saveAsync(player.getUuid());
}
}
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) {
stats.totalTime += (now - data.lastLogin);
}
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) {
totalTime += (now - data.lastLogin);
}
stats.put(playerName, "总时长: " + formatTime(totalTime) +
" | 30天: " + formatTime(data.rolling30Days.getTotalTime(now)) +
" | 7天: " + formatTime(data.rolling7Days.getTotalTime(now)));
}
});
return stats;
}
public String getPlayerName(UUID uuid) {
// 尝试获取在线玩家
ServerPlayerEntity player = server.getPlayerManager().getPlayer(uuid);
if (player != null) {
return player.getName().getString();
}
// 尝试从用户缓存获取 - 现在正确处理Optional
Optional<GameProfile> profile = server.getUserCache().getByUuid(uuid);
if (profile.isPresent()) {
return profile.get().getName();
}
return "Unknown";
}
private void loadData() {
if (!Files.exists(dataFile)) {
return;
}
try (Reader reader = Files.newBufferedReader(dataFile)) {
JsonObject root = JsonParser.parseReader(reader).getAsJsonObject();
for (Map.Entry<String, JsonElement> entry : root.entrySet()) {
UUID uuid = UUID.fromString(entry.getKey());
playerData.put(uuid, GSON.fromJson(entry.getValue(), PlayerTimeData.class));
}
} catch (Exception e) {
PlayerTimeMod.LOGGER.error("[在线时间] 无法加载玩家在线时间数据", e);
}
}
public void saveAll() {
JsonObject root = new JsonObject();
playerData.forEach((uuid, data) -> {
root.add(uuid.toString(), GSON.toJsonTree(data));
});
try (Writer writer = Files.newBufferedWriter(dataFile)) {
GSON.toJson(root, writer);
} catch (Exception e) {
PlayerTimeMod.LOGGER.error("[在线时间] 无法保存玩家在线时间数据", e);
}
}
private void saveAsync(UUID uuid) {
executor.execute(() -> {
JsonObject root;
try {
if (Files.exists(dataFile)) {
try (Reader reader = Files.newBufferedReader(dataFile)) {
root = JsonParser.parseReader(reader).getAsJsonObject();
}
} else {
root = new JsonObject();
}
} catch (Exception e) {
root = new JsonObject();
}
PlayerTimeData data = playerData.get(uuid);
if (data != null) {
root.add(uuid.toString(), GSON.toJsonTree(data));
}
try (Writer writer = Files.newBufferedWriter(dataFile)) {
GSON.toJson(root, writer);
} catch (Exception e) {
PlayerTimeMod.LOGGER.error("[在线时间] 无法保存" + uuid + "的在线时间数据", e);
}
});
}
public static String formatTime(long seconds) {
long hours = seconds / 3600;
long minutes = (seconds % 3600) / 60;
return String.format("%dh %02dm", hours, minutes);
}
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;
}
private static class RollingTimeWindow {
private final int days;
private final List<TimeEntry> entries = new ArrayList<>();
public RollingTimeWindow(int days) {
this.days = days;
}
public void addPlayTime(long timestamp, long seconds) {
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 * 3600);
entries.removeIf(entry -> entry.timestamp < cutoff);
}
private static class TimeEntry {
final long timestamp;
final long seconds;
TimeEntry(long timestamp, long seconds) {
this.timestamp = timestamp;
this.seconds = seconds;
}
}
}
public Map<UUID, PlayerTimeData> getPlayerData() {
return Collections.unmodifiableMap(playerData);
}
}

View File

@ -0,0 +1,259 @@
package com.example.playertime;
import com.google.gson.*;
import com.sun.net.httpserver.HttpServer;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpExchange;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.PlayerManager;
import net.minecraft.server.network.ServerPlayerEntity;
import java.io.*;
import java.net.InetSocketAddress;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
import static com.mojang.text2speech.Narrator.LOGGER;
public class WebServer {
private final HttpServer server;
private final PlayerTimeTracker timeTracker;
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.of(
"html", "text/html",
"css", "text/css",
"js", "application/javascript",
"json", "application/json"
);
public WebServer(PlayerTimeTracker timeTracker, int port, MinecraftServer minecraftServer) throws IOException {
this.minecraftServer = minecraftServer;
if (port < 1 || port > 65535) {
throw new IllegalArgumentException("Invalid port number: " + port);
}
this.timeTracker = timeTracker;
this.server = HttpServer.create(new InetSocketAddress(port), 0);
setupContexts();
}
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");
if (!"GET".equals(exchange.getRequestMethod())) {
sendResponse(exchange, 405, "Method Not Allowed");
return;
}
try {
// 改为使用新的白名单统计方法
Map<String, String> stats = timeTracker.getWhitelistedPlayerStats();
String response = new Gson().toJson(stats);
sendResponse(exchange, 200, response.getBytes(), "application/json");
} catch (Exception e) {
PlayerTimeMod.LOGGER.error("[在线时间] 无法获得统计数据", e);
sendResponse(exchange, 500, "Internal Server Error");
}
});
// 静态文件服务
server.createContext("/", exchange -> {
try {
String path = exchange.getRequestURI().getPath();
if (path.equals("/")) path = "/index.html";
// 从资源目录加载文件
String resourcePath = "assets/playertime/web" + path;
InputStream is = getClass().getClassLoader().getResourceAsStream(resourcePath);
if (is == null) {
sendResponse(exchange, 404, "Not Found");
return;
}
// 确定内容类型
String extension = path.substring(path.lastIndexOf('.') + 1);
String contentType = MIME_TYPES.getOrDefault(extension, "text/plain");
// 读取文件内容
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();
sendResponse(exchange, 200, buffer.toByteArray(), contentType);
} catch (Exception e) {
PlayerTimeMod.LOGGER.error("[在线时间] 无法提供资源", 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");
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 {
MinecraftServer server = minecraftServer;
PlayerManager playerManager = server.getPlayerManager();
JsonObject response = new JsonObject();
response.addProperty("onlineCount", playerManager.getCurrentPlayerCount());
// 获取白名单在线玩家
JsonArray whitelistPlayers = new JsonArray();
Map<String, Long> playerTimeMap = new HashMap<>();
// 先收集所有白名单玩家UUID
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);
playerTimeMap.put(player.getName().getString(), stats.totalTime);
}
}
}
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));
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("[在线时间] 无法获得统计数据", e);
sendResponse(exchange, 500, "Internal Server Error");
}
});
server.setExecutor(executor);
}
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()) {
os.write(response);
}
}
public void start() {
server.start();
}
public void stop() {
server.stop(0);
executor.shutdown();
}
// 以前ai留下的烂方法大概没用了注释也是ai的
private String generatePlayerList(Map<String, String> stats) {
if (stats.isEmpty()) return "<p>暂无玩家数据</p>";
StringBuilder sb = new StringBuilder("<ul style='list-style-type: none; padding-left: 5px;'>");
stats.keySet().stream().limit(5).forEach(player -> {
sb.append("<li>• ").append(player).append("</li>");
});
if (stats.size() > 5) {
sb.append("<li>... 等 ").append(stats.size() - 5).append(" 位玩家</li>");
}
sb.append("</ul>");
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 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;
}
}

View File

@ -0,0 +1,433 @@
:root {
--primary-color: #4361ee;
--primary-hover: #3a56d4;
--text-color: #2b2d42;
--bg-color: #f8f9fa;
--card-bg: #ffffff;
--border-color: #e9ecef;
--shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
--hover-bg: #f1f3f5;
--even-row: #f8f9fa;
--transition: all 0.3s ease;
--status-card-bg: #ffffff;
--status-card-text: #2b2d42;
--primary-color-c: #3252df;
--warning-text-color: #ff0800;
}
[data-theme="dark"] {
--primary-color: #3252df;
--primary-hover: #2360cf;
--text-color: #f8f9fa;
--bg-color: #121212;
--card-bg: #1e1e1e;
--border-color: #333;
--shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
--hover-bg: #2d2d2d;
--even-row: #252525;
--status-card-bg: #252525;
--status-card-text: #f8f9fa;
--primary-color-c: #5878fa;
--warning-text-color: #ff423c;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
margin: 0;
padding: 0;
background-color: var(--bg-color);
color: var(--text-color);
transition: var(--transition);
min-height: 100vh;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1.5rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
h1 {
color: var(--primary-color-c);
margin: 0;
font-size: 2rem;
font-weight: 600;
}
.controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
}
.info-note {
margin: 0;
color: var(--text-color);
opacity: 0.8;
font-size: 0.9rem;
}
button {
transition: var(--transition);
cursor: pointer;
border: none;
border-radius: 8px;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.5rem;
}
.refresh-btn {
background-color: var(--primary-color);
color: white;
padding: 0.75rem 1.25rem;
font-size: 1rem;
}
.refresh-btn:hover {
background-color: var(--primary-hover);
transform: translateY(-1px);
}
.refresh-btn:active {
transform: translateY(0);
}
.refresh-btn i {
transition: transform 0.5s ease;
}
.refresh-btn.loading i {
transform: rotate(360deg);
}
.theme-toggle {
background: transparent;
color: var(--text-color);
font-size: 1.25rem;
padding: 0.5rem;
position: relative;
width: 3rem;
height: 3rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.theme-toggle:hover {
background-color: var(--hover-bg);
}
.theme-toggle i {
position: absolute;
transition: opacity 0.3s ease, transform 0.3s ease;
}
.theme-toggle .fa-moon {
opacity: 0;
transform: scale(0.8);
}
[data-theme="dark"] .theme-toggle .fa-moon {
opacity: 1;
transform: scale(1);
}
[data-theme="dark"] .theme-toggle .fa-sun {
opacity: 0;
transform: scale(0.8);
}
.stats-container {
background-color: var(--card-bg);
border-radius: 12px;
box-shadow: var(--shadow);
overflow: hidden;
transition: var(--transition);
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 1rem 1.5rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
transition: var(--transition);
}
th {
background-color: var(--primary-color);
color: white;
font-weight: 500;
}
tr {
transition: var(--transition);
}
tr:hover {
background-color: var(--hover-bg);
}
tr:nth-child(even) {
background-color: var(--even-row);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
tbody tr {
animation: fadeIn 0.3s ease forwards;
opacity: 0;
}
tbody tr:nth-child(1) { animation-delay: 0.05s; }
tbody tr:nth-child(2) { animation-delay: 0.1s; }
tbody tr:nth-child(3) { animation-delay: 0.15s; }
tbody tr:nth-child(4) { animation-delay: 0.2s; }
tbody tr:nth-child(5) { animation-delay: 0.25s; }
tbody tr:nth-child(6) { animation-delay: 0.3s; }
tbody tr:nth-child(7) { animation-delay: 0.35s; }
tbody tr:nth-child(8) { animation-delay: 0.4s; }
tbody tr:nth-child(9) { animation-delay: 0.45s; }
tbody tr:nth-child(10) { animation-delay: 0.5s; }
@media (max-width: 768px) {
.container {
padding: 1.5rem 1rem;
}
th, td {
padding: 0.75rem 1rem;
}
table {
display: block;
overflow-x: auto;
}
}
.status-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.status-card {
background-color: var(--status-card-bg);
color: var(--status-card-text);
border-radius: 12px;
padding: 1.5rem;
box-shadow: var(--shadow);
transition: var(--transition);
}
.status-card h3 {
margin-top: 0;
margin-bottom: 1rem;
color: var(--primary-color-c);
font-size: 1.1rem;
}
.online-count {
font-size: 2rem;
font-weight: 600;
color: var(--status-card-text);
}
.online-players,
.top-players {
list-style: none;
padding: 0;
margin: 0;
}
.online-players li,
.top-players li {
padding: 0.5rem 0;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
}
.online-players li:last-child,
.top-players li:last-child {
border-bottom: none;
}
.player-name {
font-weight: 500;
}
.player-time {
color: var(--primary-color-c);
opacity: 0.8;
}
.warning {
color: var(--warning-text-color);
margin: 0;
}
@media (max-width: 600px) and (orientation: portrait) {
.container {
padding: 1rem 0.75rem;
}
h1 {
font-size: 1.5rem;
}
.header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1.5rem;
}
.status-cards {
grid-template-columns: 1fr;
gap: 1rem;
}
.status-card {
padding: 1rem;
}
.online-count {
font-size: 1.75rem;
}
table {
display: block;
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
th, td {
padding: 0.5rem;
font-size: 0.85rem;
white-space: nowrap;
}
.controls {
flex-direction: column;
align-items: flex-start;
}
.refresh-btn {
width: 100%;
justify-content: center;
padding: 0.5rem;
}
.warning {
font-size: 0.85rem;
margin-bottom: 0.5rem;
}
th:nth-child(1), td:nth-child(1) {
min-width: 100px;
}
th:nth-child(2), td:nth-child(2),
th:nth-child(3), td:nth-child(3),
th:nth-child(4), td:nth-child(4) {
min-width: 80px;
}
}
@media (max-width: 400px) {
.container {
padding: 1rem 0.5rem;
}
h1 {
font-size: 1.3rem;
}
.status-card h3 {
font-size: 1rem;
}
.player-name, .player-time {
font-size: 0.9rem;
}
}
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0,0,0,0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
display: none;
}
.loading-overlay.active {
display: flex;
}
.loading-spinner {
width: 50px;
height: 50px;
border: 5px solid rgba(255,255,255,0.3);
border-radius: 50%;
border-top-color: var(--primary-color);
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.license-footer {
margin-top: 3rem;
padding: 1.5rem 0;
text-align: center;
color: var(--text-color);
opacity: 0.8;
font-size: 0.85rem;
border-top: 1px solid var(--border-color);
}
.license-footer a {
color: var(--primary-color-c);
text-decoration: none;
}
.license-footer a:hover {
text-decoration: underline;
}
@media (max-width: 768px) {
.license-footer {
margin-top: 2rem;
padding: 1rem 0;
font-size: 0.8rem;
}
}

View File

@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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">
</head>
<body>
<div class="container">
<div class="header">
<h1>玩家在线时间统计</h1>
<button id="theme-toggle" class="theme-toggle" aria-label="切换主题">
<i class="fas fa-moon"></i>
<i class="fas fa-sun"></i>
</button>
</div>
<div class="warning">数据统计时间开始于此MOD安装时间不包含安装之前的所有数据</div>
<br>
<!-- 新增的在线状态卡片 -->
<div class="status-cards">
<div class="status-card">
<h3>当前在线</h3>
<div class="online-count" id="online-count">0</div>
</div>
<div class="status-card">
<h3>在线玩家</h3>
<ul class="online-players" id="online-players"></ul>
</div>
<div class="status-card">
<h3>时长前三</h3>
<ul class="top-players" id="top-players"></ul>
</div>
</div>
<div class="controls">
<button id="refresh-btn" class="refresh-btn">
<i class="fas fa-sync-alt"></i>
<span>刷新数据</span>
</button>
<p class="info-note">仅跟踪和显示列入白名单的玩家</p>
</div>
<div class="stats-container">
<table id="stats-table">
<thead>
<tr>
<th>玩家</th>
<th>总计时间</th>
<th>最近 30 天</th>
<th>最近 7 天</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<script src="js/app.js"></script>
<footer class="license-footer">
<div class="license-info">
<p>本项目基于 <a href="https://opensource.org/licenses/MIT" target="_blank">MIT许可证</a> 开源</p>
<p>Copyright © 2025 <a href="https://git.branulf.top/BRanulf" target="_blank">BRanulf</a></p>
</div>
</footer>
</body>
</html>

View File

@ -0,0 +1,140 @@
document.addEventListener('DOMContentLoaded', function() {
const refreshBtn = document.getElementById('refresh-btn');
const themeToggle = document.getElementById('theme-toggle');
const statsTable = document.getElementById('stats-table').getElementsByTagName('tbody')[0];
const onlineCountEl = document.getElementById('online-count');
const onlinePlayersEl = document.getElementById('online-players');
const topPlayersEl = document.getElementById('top-players');
// 初始化主题
const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', savedTheme);
// 主题切换功能
themeToggle.addEventListener('click', () => {
const currentTheme = document.documentElement.getAttribute('data-theme');
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
});
loadAllData();
refreshBtn.addEventListener('click', function() {
refreshBtn.classList.add('loading');
loadAllData();
setTimeout(() => {
refreshBtn.classList.remove('loading');
}, 1000);
});
function loadAllData() {
// 加载统计数据
fetch('/api/stats')
.then(response => response.json())
.then(data => {
updateTable(data);
})
.catch(error => {
console.error('获取统计信息时出错:', error);
showError('未能加载玩家统计信息。 检查控制台以获取详细信息');
});
// 加载在线状态数据
fetch('/api/widget-data')
.then(response => response.json())
.then(data => {
updateOnlineStatus(data);
})
.catch(error => {
console.error('获取在线状态时出错:', error);
showError('未能加载在线状态信息。 检查控制台以获取详细信息');
});
}
function updateTable(statsData) {
statsTable.innerHTML = '';
Object.entries(statsData).forEach(([playerName, statString]) => {
const row = statsTable.insertRow();
const nameCell = row.insertCell(0);
nameCell.textContent = playerName;
const stats = {};
statString.split(" | ").forEach(part => {
const [label, value] = part.split(": ");
stats[label.trim()] = value;
});
const totalCell = row.insertCell(1);
totalCell.textContent = stats["总时长"];
const thirtyCell = row.insertCell(2);
thirtyCell.textContent = stats["30天"];
const sevenCell = row.insertCell(3);
sevenCell.textContent = stats["7天"];
});
}
function updateOnlineStatus(data) {
// 更新在线人数
onlineCountEl.textContent = data.onlineCount;
// 更新在线玩家列表
onlinePlayersEl.innerHTML = '';
if (data.whitelistPlayers && data.whitelistPlayers.length > 0) {
data.whitelistPlayers.forEach(player => {
const li = document.createElement('li');
li.innerHTML = `
<span class="player-name">${player.name}</span>
<span class="player-time">${player.time}</span>
`;
onlinePlayersEl.appendChild(li);
});
} else {
onlinePlayersEl.innerHTML = '<li>暂无在线玩家</li>';
}
// 更新时长前三玩家
topPlayersEl.innerHTML = '';
if (data.topPlayers && data.topPlayers.length > 0) {
data.topPlayers.forEach(player => {
const li = document.createElement('li');
li.innerHTML = `
<span class="player-name">${player.name}</span>
<span class="player-time">${player.time}</span>
`;
topPlayersEl.appendChild(li);
});
} else {
topPlayersEl.innerHTML = '<li>暂无数据</li>';
}
}
function showError(message) {
const errorEl = document.createElement('div');
errorEl.className = 'error-message';
errorEl.textContent = message;
document.body.appendChild(errorEl);
setTimeout(() => {
errorEl.classList.add('show');
}, 10);
setTimeout(() => {
errorEl.classList.remove('show');
setTimeout(() => {
document.body.removeChild(errorEl);
}, 300);
}, 5000);
}
function parseTime(timeStr) {
const [hPart, mPart] = timeStr.split(' ');
const hours = parseInt(hPart.replace('h', ''));
const minutes = parseInt(mPart.replace('m', ''));
return hours * 60 + minutes;
}
});

View File

@ -0,0 +1,3 @@
{
"webPort": 60048
}

View File

@ -0,0 +1,24 @@
{
"schemaVersion": 1,
"id": "playertime",
"version": "${version}",
"name": "玩家在线时长统计Mod",
"description": "",
"authors": [],
"contact": {},
"license": "MIT",
"environment": "server",
"entrypoints": {
"main": [
"com.example.playertime.PlayerTimeMod"
]
},
"mixins": [
"playertime.mixins.json"
],
"depends": {
"fabricloader": ">=${loader_version}",
"fabric": "*",
"minecraft": "${minecraft_version}"
}
}

View File

@ -0,0 +1,8 @@
{
"required": true,
"package": "com.example.playertime.mixins",
"compatibilityLevel": "JAVA_21",
"injectors": {
"defaultRequire": 1
}
}

View File

@ -0,0 +1,231 @@
<!--下面全是嵌入式卡片的代码复制后找个位置直接粘贴就行个人建议body的最下面-->
<!--记得更改serverUrl地址端口后面建议别动-->
<div id="minecraft-card-container"></div>
<script>
// 感谢deepseek大爹
// ===== 配置 =====
const config = {
serverUrl: "http://localhost:60048/api/widget-data",
updateInterval: 5000,
cardWidth: "300px"
};
(function initCard() {
if(localStorage.getItem('mcCardClosed')) return;
const card = createCard();
document.getElementById('minecraft-card-container').appendChild(card);
setupDragAndClose(card);
fetchData(card).then(() => {
setInterval(() => fetchData(card), config.updateInterval);
});
setTimeout(() => card.style.display = 'block', 100);
})();
function createCard() {
const card = document.createElement('div');
card.id = 'mc-status-card';
card.style.cssText = `
position: fixed; width: ${config.cardWidth}; z-index: 99999;
right: 20px; bottom: 20px; border-radius: 12px; background: white;
box-shadow: 0 4px 20px rgba(0,0,0,0.15); font-family: 'Segoe UI', sans-serif;
overflow: hidden; transition: all 0.2s; display: none;
`;
card.innerHTML = `
<div class="card-header" style="cursor:move;background:linear-gradient(135deg,#6a11cb 0%,#2575fc 100%);
color:white;padding:12px 15px;position:relative;">
<span>服务器玩家状态</span>
<span class="close-btn" style="position:absolute;right:10px;top:10px;color:white;
opacity:0.8;cursor:pointer;transition:opacity 0.2s;">×</span>
</div>
<div class="card-body" style="padding:0;">
<div class="loading" style="padding:20px;text-align:center;">
<div style="width:20px;height:20px;margin:0 auto 10px;border:3px solid rgba(0,0,0,0.1);
border-radius:50%;border-top-color:#2575fc;animation:spin 1s linear infinite;"></div>
<p>连接服务器中...</p>
</div>
</div>
`;
return card;
}
async function fetchData(card) {
const body = card.querySelector('.card-body');
try {
// 方案1尝试直接请求如果同源
let data = await tryDirectFetch();
// 方案2如果失败则使用服务器端代理
if(!data) data = await tryServerProxy();
if(data) {
renderCard(body, data);
} else {
throw new Error('所有数据获取方式均失败');
}
} catch(error) {
body.innerHTML = `
<div style="padding:20px;text-align:center;">
<p style="color:#dc3545;">⚠️ 数据加载失败</p>
<small style="color:#6c757d;">${error.message}</small>
<button onclick="location.reload()" style="margin-top:10px;padding:5px 10px;
background:#f8f9fa;border:1px solid #ddd;border-radius:4px;cursor:pointer;">
重试
</button>
</div>
`;
console.error('数据获取失败:', error);
}
}
async function tryDirectFetch() {
try {
const response = await fetch(config.serverUrl, {
headers: { 'Accept': 'application/json' }
});
return await fetchWidgetData();
} catch {
return null;
}
}
async function fetchWidgetData() {
try {
const response = await fetch('http://localhost:60048/api/widget-data');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log('获取到的数据:', data);
return data;
} catch (error) {
console.error('获取widget数据时出错:', error);
return {
onlineCount: 0,
whitelistPlayers: [],
topPlayers: [],
timestamp: Date.now()
};
}
}
async function tryServerProxy() {
try {
// 备用的暂时不用deepseek建议留着
const proxyUrl = 'http:// ' +
encodeURIComponent(config.serverUrl);
const response = await fetch(proxyUrl);
return await response.json();
} catch {
return null;
}
}
function renderCard(container, data) {
container.innerHTML = `
<div style="display:flex;align-items:center;padding:10px 15px;border-bottom:1px solid rgba(0,0,0,0.05);">
<span style="font-size:1.2rem;margin-right:12px;color:#2575fc;">👥</span>
<span>在线玩家</span>
<span style="margin-left:auto;background:#0d6efd;color:white;
padding:0.25em 0.6em;border-radius:50rem;font-size:0.75em;">
${data.onlineCount}
</span>
</div>
${data.topPlayers?.length ? `
<div style="padding:8px 15px;background:#f8f9fa;">
<small><span style="color:#ffc107">🏆</span> 时长前三</small>
</div>
${data.topPlayers.map(p => renderPlayer(p)).join('')}
` : ''}
<div style="padding:8px 15px;background:#f8f9fa;">
<small><span style="color:#2575fc">ⓘ</span> 在线玩家</small>
</div>
<div style="max-height:150px;overflow-y:auto;">
${data.whitelistPlayers?.length ?
data.whitelistPlayers.map(p => renderPlayer(p)).join('') :
'<div style="padding:15px;text-align:center;color:#6c757d;font-style:italic">无在线玩家</div>'
}
</div>
<div style="padding:8px;text-align:center;color:#6c757d;font-size:0.8em;">
更新: ${formatTime(data.timestamp)}
</div>
`;
}
function renderPlayer(player) {
return `
<div style="display:flex;align-items:center;padding:8px 15px;border-bottom:1px solid rgba(0,0,0,0.05);">
<div style="width:24px;height:24px;border-radius:50%;background:#e9ecef;margin-right:10px;
display:flex;align-items:center;justify-content:center;font-size:12px;color:#495057;">
${player.name.charAt(0).toUpperCase()}
</div>
<span style="flex-grow:1;">${player.name}</span>
<span style="font-size:0.9rem;color:#6c757d;">${player.time}</span>
</div>
`;
}
function setupDragAndClose(card) {
const header = card.querySelector('.card-header');
let isDragging = false, offsetX, offsetY;
header.addEventListener('mousedown', (e) => {
if(e.target.classList.contains('close-btn')) return;
isDragging = true;
offsetX = e.clientX - card.getBoundingClientRect().left;
offsetY = e.clientY - card.getBoundingClientRect().top;
card.style.cursor = 'grabbing';
});
document.addEventListener('mousemove', (e) => {
if(!isDragging) return;
card.style.left = (e.clientX - offsetX) + 'px';
card.style.top = (e.clientY - offsetY) + 'px';
card.style.right = 'auto';
card.style.bottom = 'auto';
});
document.addEventListener('mouseup', () => {
isDragging = false;
card.style.cursor = 'grab';
});
card.querySelector('.close-btn').addEventListener('click', () => {
card.style.display = 'none';
localStorage.setItem('mcCardClosed', 'true');
});
}
function formatTime(timestamp) {
const now = Date.now();
const diff = (now - timestamp) / 1000;
if(diff < 60) return '刚刚';
if(diff < 3600) return `${Math.floor(diff/60)}分钟前`;
return `${Math.floor(diff/3600)}小时前`;
}
document.head.insertAdjacentHTML('beforeend', `
<style>
@keyframes spin { to { transform: rotate(360deg); } }
#mc-status-card:hover { transform: translateY(-2px); box-shadow: 0 6px 25px rgba(0,0,0,0.2); }
</style>
`);
</script>