Compare commits

...

2 Commits

7 changed files with 235 additions and 6 deletions

View File

@@ -1,8 +1,7 @@
<template> <template>
<header> <header>
<nav> <nav>
<router-link to="/">Главная</router-link> | <router-link to="/">Главная</router-link> | <router-link to="/servers">Мониторинг</router-link> |
<template v-if="!authStore.isAuthenticated"> <template v-if="!authStore.isAuthenticated">
<router-link to="/login">Вход</router-link> | <router-link to="/login">Вход</router-link> |
<router-link to="/register">Регистрация</router-link> <router-link to="/register">Регистрация</router-link>

7
src/api/user.ts Normal file
View File

@@ -0,0 +1,7 @@
import apiClient from "./axios";
import type { SessionProfileResponse } from "@/types";
export const getUserProfile = (uuid: string) => {
// Этот эндпоинт не начинается с /api, поэтому указываем полный путь
return apiClient.get<SessionProfileResponse>(`/sessionserver/session/minecraft/profile/${uuid}`);
};

View File

@@ -1,5 +1,6 @@
import { createRouter, createWebHistory } from "vue-router"; import { createRouter, createWebHistory } from "vue-router";
import HomeView from "../views/HomeView.vue"; import HomeView from "../views/HomeView.vue";
import { useAuthStore } from "@/stores/auth";
const routes = [ const routes = [
{ {
@@ -23,6 +24,11 @@ const routes = [
component: () => import("../views/AccountView.vue"), component: () => import("../views/AccountView.vue"),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{
path: "/servers",
name: "servers",
component: () => import("../views/ServersView.vue"),
},
]; ];
const router = createRouter({ const router = createRouter({

View File

@@ -1,6 +1,7 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import { registerUser as apiRegisterUser } from "@/api/auth"; import { registerUser as apiRegisterUser } from "@/api/auth";
import { getUserProfile as apiGetUserProfile } from "@/api/user";
import type { RegisterRequest } from "@/types"; import type { RegisterRequest } from "@/types";
import router from "@/router"; import router from "@/router";
import apiClient from "@/api/axios"; import apiClient from "@/api/axios";
@@ -9,6 +10,7 @@ export const useAuthStore = defineStore("auth", () => {
// State // State
const user = ref<User | null>(null); const user = ref<User | null>(null);
const token = ref<string | null>(localStorage.getItem("authToken")); const token = ref<string | null>(localStorage.getItem("authToken"));
const skinUrl = ref<string | null>(null);
const isLoading = ref(false); const isLoading = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
@@ -16,11 +18,34 @@ export const useAuthStore = defineStore("auth", () => {
const isAuthenticated = computed(() => !!user.value && !!token.value); const isAuthenticated = computed(() => !!user.value && !!token.value);
// Actions // Actions
async function fetchUserProfile() {
if (!user.value) return;
try {
const response = await apiGetUserProfile(user.value.uuid);
if (response.data && response.data.properties) {
const textureProp = response.data.properties.find((p) => p.name === "textures");
if (textureProp) {
// Декодируем Base64-строку и парсим JSON
const textureData = JSON.parse(atob(textureProp.value));
if (textureData.textures?.SKIN?.url) {
skinUrl.value = textureData.textures.SKIN.url;
} else {
skinUrl.value = null; // У пользователя нет скина
}
}
}
} catch (e) {
console.error("Failed to fetch user profile:", e);
skinUrl.value = null;
}
}
function setAuthData(userData: User, authToken: string) { function setAuthData(userData: User, authToken: string) {
user.value = userData; user.value = userData;
token.value = authToken; token.value = authToken;
localStorage.setItem("authToken", authToken); localStorage.setItem("authToken", authToken);
apiClient.defaults.headers.common["Authorization"] = `Bearer ${authToken}`; apiClient.defaults.headers.common["Authorization"] = `Bearer ${authToken}`;
fetchUserProfile();
} }
async function handleLogin(credentials: LoginRequest) { async function handleLogin(credentials: LoginRequest) {
@@ -61,15 +86,24 @@ export const useAuthStore = defineStore("auth", () => {
function handleLogout() { function handleLogout() {
user.value = null; user.value = null;
token.value = null; token.value = null;
skinUrl.value = null;
localStorage.removeItem("authToken"); localStorage.removeItem("authToken");
delete apiClient.defaults.headers.common["Authorization"]; delete apiClient.defaults.headers.common["Authorization"];
router.push({ name: "login" }); router.push({ name: "login" });
} }
async function checkAuth() {
if (token.value) {
if (user.value) {
await fetchUserProfile();
}
}
}
return { return {
// State // State
user, user,
token, token,
skinUrl,
isLoading, isLoading,
error, error,
// Getters // Getters
@@ -78,5 +112,7 @@ export const useAuthStore = defineStore("auth", () => {
handleLogin, handleLogin,
handleRegister, handleRegister,
handleLogout, handleLogout,
fetchUserProfile,
checkAuth,
}; };
}); });

View File

@@ -26,3 +26,15 @@ export interface User {
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
export interface ProfileProperty {
name: string;
value: string; // Base64-encoded JSON
signature: string;
}
export interface SessionProfileResponse {
id: string;
name: string;
properties: ProfileProperty[];
}

View File

@@ -71,23 +71,49 @@ const onSkinUpload = async () => {
if (skinViewer) { if (skinViewer) {
skinViewer.loadSkin(URL.createObjectURL(selectedFile.value)); skinViewer.loadSkin(URL.createObjectURL(selectedFile.value));
} }
await authStore.fetchUserProfile();
} catch (e: any) { } catch (e: any) {
uploadError.value = e.response?.data || "Ошибка при загрузке файла."; uploadError.value = e.response?.data || "Ошибка при загрузке файла.";
} finally { } finally {
isLoading.value = false; isLoading.value = false;
} }
}; };
// --- Логика для 3D-вьювера ---
const skinCanvas = ref<HTMLCanvasElement | null>(null);
let skinViewer: SkinViewer | null = null;
// Инициализация 3D-вьювера при монтировании компонента const setupSkinViewer = () => {
onMounted(() => { if (skinCanvas.value && !skinViewer) {
if (skinCanvas.value) {
skinViewer = new SkinViewer({ skinViewer = new SkinViewer({
canvas: skinCanvas.value, canvas: skinCanvas.value,
width: 300, width: 300,
height: 400, height: 400,
skin: "/default_skin.png",
}); });
} }
};
// Следим за изменением URL скина в сторе и обновляем вьювер
watch(
() => authStore.skinUrl,
(newUrl) => {
if (skinViewer) {
if (newUrl) {
skinViewer.loadSkin(newUrl);
} else {
// Если у пользователя нет скина, показываем скин по умолчанию
skinViewer.loadSkin("/default_skin.png");
}
}
},
{ immediate: true },
);
// Инициализация 3D-вьювера при монтировании компонента
onMounted(() => {
setupSkinViewer();
if (!authStore.skinUrl && authStore.isAuthenticated) {
authStore.fetchUserProfile();
}
}); });
// Очистка ресурсов при размонтировании // Очистка ресурсов при размонтировании

143
src/views/ServersView.vue Normal file
View File

@@ -0,0 +1,143 @@
<template>
<div class="servers-container">
<h1>Мониторинг серверов</h1>
<div v-if="isLoading" class="loading">Загрузка данных...</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div class="ping-info">
Ваш пинг до сервера:
<span v-if="clientProxyPing !== null">{{ clientProxyPing }} мс</span>
<span v-else>измерение...</span>
</div>
<div class="server-list">
<div v-for="server in servers" :key="server.id" class="server-card">
<h3>{{ server.name }}</h3>
<p class="motd" v-html="formatMotd(server.motd)"></p>
<div class="status">
<span class="online-status" :class="{ 'is-online': server.player_count !== null }">
{{ server.player_count !== null ? "Online" : "Offline" }}
</span>
<span class="players" v-if="server.player_count !== null"> {{ server.player_count }} / {{ server.max_players }} </span>
</div>
<div class="ping">
Общий пинг:
<span v-if="totalPing(server) !== null">{{ totalPing(server) }} мс</span>
<span v-else>N/A</span>
</div>
<div class="version">{{ server.version_name }}</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import apiClient from "@/api/axios";
// Тип для сервера, можно вынести в types.ts
interface GameServer {
id: number;
name: string;
motd: string | null;
player_count: number | null;
max_players: number | null;
version_name: string | null;
ping_proxy_server: number | null;
}
const servers = ref<GameServer[]>([]);
const isLoading = ref(true);
const error = ref<string | null>(null);
const clientProxyPing = ref<number | null>(null);
let ws: WebSocket | null = null;
let pingInterval: number | null = null;
const fetchServers = async () => {
try {
const response = await apiClient.get<GameServer[]>("/servers");
servers.value = response.data;
} catch (e) {
error.value = "Не удалось загрузить список серверов.";
} finally {
isLoading.value = false;
}
};
const setupWebSocket = () => {
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${wsProtocol}//${window.location.host}/ws/ping`;
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log("WebSocket connection established.");
pingInterval = window.setInterval(() => {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(Date.now().toString());
}
}, 5000);
};
ws.onmessage = (event) => {
const serverTimestamp = parseInt(event.data, 10);
clientProxyPing.value = Date.now() - serverTimestamp;
};
ws.onclose = () => {
console.log("WebSocket connection closed.");
if (pingInterval) clearInterval(pingInterval);
};
ws.onerror = (err) => {
console.error("WebSocket error:", err);
error.value = "Ошибка подключения к WebSocket.";
};
};
const totalPing = (server: GameServer) => {
if (clientProxyPing.value !== null && server.ping_proxy_server !== null) {
return clientProxyPing.value + server.ping_proxy_server;
}
return null;
};
// Функция для форматирования MOTD с кодами цвета Minecraft
const formatMotd = (motd: string | null) => {
if (!motd) return "";
return motd.replace(/§[0-9a-fk-or]/g, "");
};
onMounted(() => {
fetchServers();
setupWebSocket();
});
onUnmounted(() => {
if (ws) {
ws.close();
}
if (pingInterval) {
clearInterval(pingInterval);
}
});
</script>
<style scoped>
/* ... добавьте стили для карточек серверов ... */
.server-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.server-card {
border: 1px solid #ccc;
border-radius: 8px;
padding: 1rem;
}
.ping-info {
margin-bottom: 1rem;
font-weight: bold;
}
</style>