Compare commits

...

3 Commits

Author SHA1 Message Date
4ce5faed59 Добавил звук печатающей клавиатуры 2026-04-21 12:57:35 +03:00
a20f80453b добавил на страницы загрузок эффект матрицы 2026-04-21 12:37:22 +03:00
7baed89b6a 1. исправил agent.py
2. поменял явки и пароли для создания операторов
3. убрал из maches.html оператора, и добавил галочку завершенные матчи
2026-04-21 11:44:58 +03:00
10 changed files with 1096 additions and 137 deletions

View File

@@ -10,8 +10,8 @@ import requests
import websockets import websockets
VMIX_API = "http://127.0.0.1:8088/api" VMIX_API = "http://127.0.0.1:8088/api"
# WS_BASE = "wss://hostname.tvstart.ru/ws/vmix-client" WS_BASE = "wss://wfl.tvstart.ru/ws/vmix-client"
WS_BASE = "ws://127.0.0.1:8000/ws/vmix-client" # WS_BASE = "ws://127.0.0.1:8000/ws/vmix-client"
CLIENT_ID = f"vmix-{socket.gethostname().lower()}-{uuid.uuid4().hex[:6]}" CLIENT_ID = f"vmix-{socket.gethostname().lower()}-{uuid.uuid4().hex[:6]}"
POLL_INTERVAL = 3 POLL_INTERVAL = 3

90
app.py
View File

@@ -459,8 +459,15 @@ def admin_matches(
request: Request, request: Request,
today_only: bool = Query(default=False), today_only: bool = Query(default=False),
tour: str | None = Query(default=None), tour: str | None = Query(default=None),
hide_finished: str = Query(default="true"),
): ):
matches = list_matches_for_admin(today_only=today_only, tour=tour) hide_finished_bool = str(hide_finished).lower() == "true"
matches = list_matches_for_admin(
today_only=today_only,
tour=tour,
hide_finished=hide_finished_bool,
)
tours = list_available_tours() tours = list_available_tours()
return templates.TemplateResponse( return templates.TemplateResponse(
@@ -469,13 +476,14 @@ def admin_matches(
context={ context={
"matches": matches, "matches": matches,
"today_only": today_only, "today_only": today_only,
"hide_finished": hide_finished_bool,
"selected_tour": tour or "", "selected_tour": tour or "",
"tours": tours, "tours": tours,
"current_user": getattr(request.state, "current_user", None), "current_user": getattr(request.state, "current_user", None),
}, },
) )
# @app.get("/admin/matches/{match_id}/select") # @app.get("/admin/matches/{match_id}/select")
# def select_match( # def select_match(
# request: Request, match_id: int, operator_name: str = Form(default="") # request: Request, match_id: int, operator_name: str = Form(default="")
@@ -519,7 +527,7 @@ def select_match(
session_row = create_match_session( session_row = create_match_session(
match_id=match_id, match_id=match_id,
operator_name=operator_name.strip() or None, operator_name=None,
vmix_project_path=None, vmix_project_path=None,
) )
@@ -533,7 +541,7 @@ def select_match(
match_id=match_id, match_id=match_id,
session_token=session_token, session_token=session_token,
details={ details={
"operator_name": operator_name.strip() or None, "operator_name": None,
}, },
) )
@@ -620,33 +628,73 @@ def session_workspace(
@app.post("/admin/session/{session_token}/load-match-data") @app.post("/admin/session/{session_token}/load-match-data")
def load_match_data(session_token: str): def load_match_data(request: Request, session_token: str):
session_row = get_match_session_by_token(session_token) session_row = get_match_session_by_token(session_token)
if not session_row: if not session_row:
if request.headers.get("x-requested-with") == "XMLHttpRequest":
return JSONResponse(
{
"success": False,
"error": "Сессия не найдена",
"redirect_url": "/admin/matches",
},
status_code=404,
)
return RedirectResponse(url="/admin/matches", status_code=303) return RedirectResponse(url="/admin/matches", status_code=303)
match_id = session_row[1] match_id = session_row[1]
match_external_id = session_row[8] match_external_id = session_row[8]
run_parser_game(str(match_external_id))
# сброс ручного состава и тренеров, чтобы подтянулись свежие данные парсера
from db import get_connection
conn = get_connection()
try: try:
with conn.cursor() as cur: run_parser_game(str(match_external_id))
cur.execute(
"DELETE FROM match_lineup_players WHERE match_id = %s", (match_id,) # сброс ручного состава и тренеров, чтобы подтянулись свежие данные парсера
from db import get_connection
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM match_lineup_players WHERE match_id = %s", (match_id,)
)
cur.execute("DELETE FROM match_coaches WHERE match_id = %s", (match_id,))
conn.commit()
finally:
conn.close()
except Exception as e:
error_text = str(e) or "Ошибка загрузки данных с сайта"
if request.headers.get("x-requested-with") == "XMLHttpRequest":
return JSONResponse(
{
"success": False,
"error": error_text,
"redirect_url": f"/admin/session/{session_token}?tab=game",
},
status_code=500,
) )
cur.execute("DELETE FROM match_coaches WHERE match_id = %s", (match_id,))
conn.commit()
finally:
conn.close()
return RedirectResponse(url=f"/admin/session/{session_token}", status_code=303) return RedirectResponse(
url=f"/admin/session/{session_token}?tab=game",
status_code=303,
)
if request.headers.get("x-requested-with") == "XMLHttpRequest":
return JSONResponse(
{
"success": True,
"message": "Данные успешно загружены",
"redirect_url": f"/admin/session/{session_token}?tab=game",
}
)
return RedirectResponse(
url=f"/admin/session/{session_token}?tab=game",
status_code=303,
)
@app.post("/admin/session/{session_token}/close") @app.post("/admin/session/{session_token}/close")
def close_session(session_token: str): def close_session(session_token: str):
deactivate_match_session(session_token) deactivate_match_session(session_token)
@@ -1354,7 +1402,7 @@ EMPTY_PLAYER_FORMATION = {
"number": " ", "number": " ",
"number_lastname_amp_K": " ", "number_lastname_amp_K": " ",
"position": " ", "position": " ",
"photo": " ", "photo": r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo\EMPTY.png",
} }

View File

@@ -207,3 +207,45 @@ def list_available_tours():
return tours return tours
finally: finally:
conn.close() conn.close()
def list_matches_for_admin(
today_only: bool = False,
tour: str | None = None,
hide_finished: bool = True,
):
query = """
SELECT
m.id,
m.external_id,
m.match_date,
m.status,
m.tour,
m.season,
ht.name AS home_team_name,
at.name AS away_team_name
FROM matches m
JOIN teams ht ON ht.id = m.home_team_id
JOIN teams at ON at.id = m.away_team_id
WHERE 1=1
"""
params = []
if today_only:
query += " AND DATE(m.match_date) = CURRENT_DATE "
if tour:
query += " AND m.tour = %s "
params.append(tour)
if hide_finished:
query += " AND COALESCE(m.status, 'scheduled') <> 'finished' "
query += " ORDER BY m.match_date ASC NULLS LAST, m.id ASC; "
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(query, params)
return cur.fetchall()
finally:
conn.close()

View File

@@ -1,23 +1,13 @@
import os import os
import sys import sys
from getpass import getpass from getpass import getpass
from db import get_connection
sys.path.append(os.path.dirname(os.path.dirname(__file__))) sys.path.append(os.path.dirname(os.path.dirname(__file__)))
import psycopg2
from services.auth_service import hash_password from services.auth_service import hash_password
def get_connection():
return psycopg2.connect(
host=os.getenv("DB_HOST", "localhost"),
port=os.getenv("DB_PORT", 5432),
dbname=os.getenv("DB_NAME", "wfl_db"),
user=os.getenv("DB_USER", "postgres"),
password=os.getenv("DB_PASSWORD", "159753"),
)
def main(): def main():
username = input("Username: ").strip() username = input("Username: ").strip()
password = getpass("Password: ").strip() password = getpass("Password: ").strip()

View File

@@ -2174,4 +2174,70 @@ body:not(.edit-mode-on) .referee-search {
background: var(--accent-hover); background: var(--accent-hover);
border-color: var(--accent-hover); border-color: var(--accent-hover);
transform: translateY(-1px); transform: translateY(-1px);
}
.matrix-loader {
position: fixed;
inset: 0;
z-index: 99999;
display: none;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.93);
overflow: hidden;
}
.matrix-loader.active {
display: flex;
}
.matrix-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.matrix-content {
position: relative;
z-index: 2;
text-align: center;
padding: 24px 28px;
border-radius: 16px;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(0, 255, 120, 0.25);
box-shadow: 0 0 30px rgba(0, 255, 120, 0.12);
backdrop-filter: blur(4px);
}
.matrix-title {
margin-bottom: 10px;
font-size: 28px;
font-weight: 700;
color: #9dffb2;
text-shadow: 0 0 10px rgba(0, 255, 120, 0.35);
}
.matrix-status {
font-size: 15px;
color: #d7ffe1;
opacity: 0.95;
}
.matrix-loader.is-error .matrix-content {
border-color: rgba(255, 90, 90, 0.35);
box-shadow: 0 0 30px rgba(255, 90, 90, 0.12);
}
.matrix-loader.is-error .matrix-title {
color: #ff9c9c;
text-shadow: 0 0 10px rgba(255, 90, 90, 0.25);
}
.matrix-loader.is-error .matrix-status {
color: #ffd4d4;
}
.matrix-loader.is-success .matrix-title {
color: #9dffb2;
} }

View File

@@ -2,109 +2,569 @@
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>WFL Admin Login</title> <meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate, max-age=0">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>Вход</title>
<style> <style>
:root { :root {
--bg: #0f1115; --bg: #000000;
--panel: #171a21; --green: #00ff66;
--panel-2: #1d222b; --green-soft: #00cc55;
--border: #2b3240; --green-dim: rgba(0, 255, 102, 0.7);
--text: #e8ecf3; --panel: rgba(0, 20, 0, 0.22);
--muted: #9aa4b2; --border: rgba(0, 255, 102, 0.22);
--accent: #4f8cff; --danger: #ff5f56;
--accent-hover: #3e78e6;
--danger-bg: rgba(220, 53, 69, 0.14);
--danger-border: rgba(220, 53, 69, 0.45);
--info-bg: rgba(79, 140, 255, 0.14);
--info-border: rgba(79, 140, 255, 0.4);
--shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
--radius: 16px;
} }
* { box-sizing: border-box; }
body { * {
box-sizing: border-box;
}
html, body {
margin: 0; margin: 0;
min-height: 100vh; width: 100%;
display: grid; height: 100%;
place-items: center; overflow: hidden;
padding: 24px;
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--green);
font-family: Arial, sans-serif; font-family: "Courier New", monospace;
} }
.card {
body {
position: relative;
}
#matrixCanvas {
position: fixed;
inset: 0;
width: 100%; width: 100%;
max-width: 420px; height: 100%;
background: var(--panel); z-index: 0;
border: 1px solid var(--border); opacity: 0.22;
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 28px;
} }
h1 { margin: 0 0 8px; font-size: 28px; }
.subtitle { color: var(--muted); margin-bottom: 22px; } .screen {
.alert { position: relative;
border: 1px solid var(--border); z-index: 1;
border-radius: 12px;
padding: 12px 14px;
margin-bottom: 16px;
font-size: 14px;
}
.alert-error { background: var(--danger-bg); border-color: var(--danger-border); }
.alert-info { background: var(--info-bg); border-color: var(--info-border); }
label { display: block; font-size: 14px; margin-bottom: 8px; }
input {
width: 100%; width: 100%;
min-height: 44px; height: 100%;
margin-bottom: 14px; display: flex;
padding: 0 12px; align-items: center;
border-radius: 10px; justify-content: center;
padding: 24px;
}
.terminal {
width: min(900px, 100%);
min-height: 420px;
padding: 32px;
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--panel-2); background: linear-gradient(
color: var(--text); 180deg,
outline: none; rgba(0, 18, 0, 0.35) 0%,
rgba(0, 0, 0, 0.72) 100%
);
box-shadow:
0 0 24px rgba(0, 255, 102, 0.08),
inset 0 0 30px rgba(0, 255, 102, 0.04);
backdrop-filter: blur(2px);
} }
input:focus {
border-color: var(--accent); .terminal-header {
box-shadow: 0 0 0 3px rgba(79, 140, 255, 0.15); display: flex;
align-items: center;
gap: 8px;
margin-bottom: 24px;
} }
button {
width: 100%; .dot {
min-height: 46px; width: 10px;
border: none; height: 10px;
border-radius: 10px; border-radius: 999px;
background: var(--accent); background: var(--green);
color: white; box-shadow: 0 0 8px rgba(0, 255, 102, 0.5);
font-weight: 700; }
cursor: pointer;
.terminal-title {
margin-left: 8px;
font-size: 13px;
color: var(--green-dim);
letter-spacing: 0.12em;
text-transform: uppercase;
}
.terminal-body {
font-size: 28px;
line-height: 1.8;
text-shadow: 0 0 10px rgba(0, 255, 102, 0.18);
}
.term-line {
display: block;
min-height: 1.8em;
}
.prompt-line {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.prompt {
color: var(--green);
margin-right: 10px;
}
.input-wrap {
position: relative;
display: inline-flex;
align-items: center;
min-width: 220px;
}
.fake-input {
position: absolute;
left: -9999px;
opacity: 0;
pointer-events: none;
}
.typed-value {
color: var(--green);
}
.caret {
display: inline-block;
width: 12px;
margin-left: 2px;
color: var(--green);
animation: blink 1s steps(1, end) infinite;
}
.hidden {
display: none;
}
.sys-message {
margin-top: 18px;
font-size: 16px;
color: var(--green-dim);
line-height: 1.5;
}
.sys-error {
color: var(--danger);
text-shadow: 0 0 8px rgba(255, 95, 86, 0.25);
}
.subtle {
opacity: 0.8;
}
.scanline::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 2;
background: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.02),
rgba(255, 255, 255, 0.00) 3px
);
background-size: 100% 4px;
opacity: 0.08;
}
@keyframes blink {
0%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
@media (max-width: 700px) {
.terminal {
padding: 22px;
min-height: 360px;
}
.terminal-body {
font-size: 20px;
line-height: 1.9;
}
.sys-message {
font-size: 14px;
}
} }
button:hover { background: var(--accent-hover); }
.hint { color: var(--muted); font-size: 13px; margin-top: 12px; }
</style> </style>
</head> </head>
<body> <body class="scanline">
<div class="card"> <canvas id="matrixCanvas"></canvas>
<h1>WFL Admin</h1>
<div class="subtitle">Вход в административный интерфейс</div>
{% if message %} <div class="screen">
<div class="alert alert-info">{{ message }}</div> <div class="terminal">
{% endif %} <div class="terminal-header">
<span class="dot"></span>
<span class="dot" style="opacity: 0.7;"></span>
<span class="dot" style="opacity: 0.45;"></span>
<div class="terminal-title">wfl access terminal</div>
</div>
{% if error %} <div class="terminal-body" id="terminalBody">
<div class="alert alert-error">{{ error }}</div> <div class="term-line" id="welcomeLine"></div>
{% endif %}
<form method="post" action="/login"> <div class="term-line hidden" id="loginLine">
<label for="username">Логин</label> <span id="loginPrompt" class="prompt"></span>
<input id="username" name="username" type="text" autocomplete="username" required> <span id="usernameText" class="typed-value"></span>
<span id="usernameCaret" class="caret"></span>
<input id="usernameInput" class="fake-input" type="text" autocomplete="username" spellcheck="false">
</div>
<label for="password">Пароль</label> <div class="term-line hidden" id="passwordLine">
<input id="password" name="password" type="password" autocomplete="current-password" required> <span id="passwordPrompt" class="prompt"></span>
<span id="passwordText" class="typed-value"></span>
<span id="passwordCaret" class="caret hidden"></span>
<input id="passwordInput" class="fake-input" type="password" autocomplete="current-password" spellcheck="false">
</div>
<button type="submit">Войти</button> <div class="term-line hidden subtle" id="submitLine">
</form> authorizing...
</div>
<div class="hint">Сессия автоматически завершится после 2 часов без активности.</div> {% if message %}
<div class="sys-message">{{ message }}</div>
{% endif %}
{% if error %}
<div class="sys-message sys-error">{{ error }}</div>
{% endif %}
</div>
</div>
</div> </div>
<form id="realLoginForm" method="post" action="/login" class="hidden">
<input type="hidden" name="username" id="realUsername">
<input type="hidden" name="password" id="realPassword">
</form>
<script>
(function () {
const canvas = document.getElementById("matrixCanvas");
const ctx = canvas ? canvas.getContext("2d") : null;
let matrixAnimationId = null;
let drops = [];
const fontSize = 18;
const chars = "アイウエオカキクケコサシスセソ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZЖФЛ<>+*#";
let columns = 0;
let lastTime = 0;
const fps = 18;
const interval = 1000 / fps;
function resizeCanvas() {
if (!canvas || !ctx) return;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
columns = Math.floor(canvas.width / fontSize);
drops = Array(columns).fill(1);
}
function drawMatrix(time = 0) {
if (!canvas || !ctx) return;
if (time - lastTime < interval) {
matrixAnimationId = requestAnimationFrame(drawMatrix);
return;
}
lastTime = time;
ctx.fillStyle = "rgba(0, 0, 0, 0.12)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#00ff66";
ctx.font = fontSize + "px monospace";
for (let i = 0; i < drops.length; i++) {
const text = chars[Math.floor(Math.random() * chars.length)];
ctx.fillText(text, i * fontSize, drops[i] * fontSize);
if (drops[i] * fontSize > canvas.height && Math.random() > 0.985) {
drops[i] = 0;
}
drops[i] += 0.65;
}
matrixAnimationId = requestAnimationFrame(drawMatrix);
}
resizeCanvas();
drawMatrix();
window.addEventListener("resize", resizeCanvas);
const welcomeText = "добро пожаловать в ЖФЛ";
const welcomeLine = document.getElementById("welcomeLine");
const loginLine = document.getElementById("loginLine");
const passwordLine = document.getElementById("passwordLine");
const submitLine = document.getElementById("submitLine");
const loginPrompt = document.getElementById("loginPrompt");
const passwordPrompt = document.getElementById("passwordPrompt");
const usernameInput = document.getElementById("usernameInput");
const passwordInput = document.getElementById("passwordInput");
const usernameText = document.getElementById("usernameText");
const passwordText = document.getElementById("passwordText");
const usernameCaret = document.getElementById("usernameCaret");
const passwordCaret = document.getElementById("passwordCaret");
const realLoginForm = document.getElementById("realLoginForm");
const realUsername = document.getElementById("realUsername");
const realPassword = document.getElementById("realPassword");
const hasServerError = !!document.querySelector(".sys-error");
let phase = "waiting_start";
let started = false;
let audioCtx = null;
function ensureAudioContext() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
if (audioCtx.state === "suspended") {
audioCtx.resume();
}
return audioCtx;
}
function playKeySound() {
const ctx = ensureAudioContext();
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.type = "square";
oscillator.frequency.setValueAtTime(900, ctx.currentTime);
gainNode.gain.setValueAtTime(0.012, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.035);
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
oscillator.start();
oscillator.stop(ctx.currentTime + 0.035);
}
function typeText(target, text, speed = 60, callback, withSound = true) {
let i = 0;
function typing() {
if (i < text.length) {
target.textContent += text[i];
if (withSound) playKeySound();
i++;
setTimeout(typing, speed);
} else if (callback) {
callback();
}
}
typing();
}
function focusCurrentInput() {
if (phase === "login") {
usernameInput.focus();
} else if (phase === "password") {
passwordInput.focus();
}
}
function showLoginStep(prefill = "") {
phase = "typing_login";
loginLine.classList.remove("hidden");
usernameCaret.classList.add("hidden");
passwordCaret.classList.add("hidden");
loginPrompt.textContent = "";
usernameText.textContent = "";
usernameInput.value = "";
typeText(loginPrompt, "login:", 60, () => {
if (prefill) {
usernameInput.value = prefill;
usernameText.textContent = prefill;
}
phase = "login";
usernameCaret.classList.remove("hidden");
usernameInput.focus();
});
}
function showPasswordStep(prefill = "") {
phase = "typing_password";
passwordLine.classList.remove("hidden");
usernameCaret.classList.add("hidden");
passwordCaret.classList.add("hidden");
passwordPrompt.textContent = "";
passwordText.textContent = "";
passwordInput.value = "";
typeText(passwordPrompt, "password:", 60, () => {
if (prefill) {
passwordInput.value = prefill;
passwordText.textContent = "*".repeat(prefill.length);
}
phase = "password";
passwordCaret.classList.remove("hidden");
passwordInput.focus();
});
}
function submitLogin() {
phase = "submitting";
usernameCaret.classList.add("hidden");
passwordCaret.classList.add("hidden");
submitLine.classList.remove("hidden");
realUsername.value = usernameInput.value.trim();
realPassword.value = passwordInput.value;
setTimeout(() => {
realLoginForm.submit();
}, 350);
}
function startSequence(options = {}) {
if (started) return;
started = true;
const skipWelcome = !!options.skipWelcome;
const usernamePrefill = options.usernamePrefill || "";
ensureAudioContext();
phase = "typing_welcome";
welcomeLine.textContent = "";
loginPrompt.textContent = "";
passwordPrompt.textContent = "";
usernameText.textContent = "";
passwordText.textContent = "";
usernameInput.value = "";
passwordInput.value = "";
submitLine.classList.add("hidden");
loginLine.classList.add("hidden");
passwordLine.classList.add("hidden");
usernameCaret.classList.add("hidden");
passwordCaret.classList.add("hidden");
if (skipWelcome) {
welcomeLine.textContent = welcomeText;
setTimeout(() => showLoginStep(usernamePrefill), 120);
return;
}
typeText(welcomeLine, welcomeText, 75, () => {
setTimeout(() => showLoginStep(usernamePrefill), 220);
});
}
usernameInput.addEventListener("input", function () {
usernameText.textContent = usernameInput.value;
playKeySound();
});
passwordInput.addEventListener("input", function () {
passwordText.textContent = "*".repeat(passwordInput.value.length);
playKeySound();
});
usernameInput.addEventListener("keydown", function (event) {
if (event.key === "Enter") {
playKeySound();
event.preventDefault();
if (!usernameInput.value.trim()) {
return;
}
usernameCaret.classList.add("hidden");
showPasswordStep();
}
});
passwordInput.addEventListener("keydown", function (event) {
if (event.key === "Enter") {
playKeySound();
event.preventDefault();
if (!usernameInput.value.trim() || !passwordInput.value) {
return;
}
submitLogin();
}
});
document.addEventListener("click", function () {
if (!started) {
startSequence();
return;
}
focusCurrentInput();
});
document.addEventListener("keydown", function (event) {
if (!started) {
event.preventDefault();
startSequence();
return;
}
if (
phase === "typing_welcome" ||
phase === "typing_login" ||
phase === "typing_password" ||
phase === "submitting"
) {
return;
}
if (document.activeElement === usernameInput || document.activeElement === passwordInput) {
return;
}
if (event.key === "Tab") {
event.preventDefault();
focusCurrentInput();
return;
}
if (event.key.length === 1 || event.key === "Backspace") {
focusCurrentInput();
}
});
if (hasServerError) {
startSequence({ skipWelcome: true });
}
})();
</script>
</body> </body>
</html> </html>

View File

@@ -53,10 +53,11 @@
{% endif %} {% endif %}
</div> </div>
<div class="header-actions compact"> <div class="header-actions compact">
<form <form
method="post" method="post"
action="/admin/session/{{ session[3] }}/load-match-data" action="/admin/session/{{ session[3] }}/load-match-data"
> id="loadMatchDataForm"
>
<button <button
type="submit" type="submit"
class="icon-btn header-icon" class="icon-btn header-icon"
@@ -1173,5 +1174,157 @@
})(); })();
</script> </script>
<script src="/static/script.js"></script> <script src="/static/script.js"></script>
<div id="matrixLoader" class="matrix-loader" aria-hidden="true">
<canvas id="matrixCanvas" class="matrix-canvas"></canvas>
<div class="matrix-content">
<div class="matrix-title">Загрузка данных матча</div>
<div class="matrix-status" id="matrixStatusText">
Подключение к сайту...
</div>
</div>
</div>
<script>
(function () {
const loadForm = document.getElementById("loadMatchDataForm");
const loader = document.getElementById("matrixLoader");
const titleEl = document.getElementById("matrixTitle");
const statusText = document.getElementById("matrixStatusText");
const canvas = document.getElementById("matrixCanvas");
const ctx = canvas ? canvas.getContext("2d") : null;
let matrixAnimationId = null;
let drops = [];
const fontSize = 18;
const chars = "アイウエオカキクケコサシスセソ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZЖФЛ<>+*#";
let columns = 0;
let lastTime = 0;
const fps = 18;
const interval = 1000 / fps;
function resizeCanvas() {
if (!canvas || !ctx) return;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
columns = Math.floor(canvas.width / fontSize);
drops = Array(columns).fill(1);
}
function drawMatrix(time = 0) {
if (!canvas || !ctx) return;
if (time - lastTime < interval) {
matrixAnimationId = requestAnimationFrame(drawMatrix);
return;
}
lastTime = time;
ctx.fillStyle = "rgba(0, 0, 0, 0.12)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#00ff66";
ctx.font = fontSize + "px monospace";
for (let i = 0; i < drops.length; i++) {
const text = chars[Math.floor(Math.random() * chars.length)];
ctx.fillText(text, i * fontSize, drops[i] * fontSize);
if (drops[i] * fontSize > canvas.height && Math.random() > 0.985) {
drops[i] = 0;
}
// drops[i] += 0.65;
drops[i] ++;
}
matrixAnimationId = requestAnimationFrame(drawMatrix);
}
function startLoader(title, message) {
if (!loader) return;
loader.classList.add("active");
loader.classList.remove("is-error", "is-success");
if (titleEl) titleEl.textContent = title || "Загрузка данных матча";
if (statusText) statusText.textContent = message || "Подключение к сайту...";
document.body.style.overflow = "hidden";
resizeCanvas();
if (!matrixAnimationId) {
drawMatrix();
}
}
function setLoaderState(state, title, message) {
if (!loader) return;
loader.classList.remove("is-error", "is-success");
if (state) {
loader.classList.add(state);
}
if (titleEl) titleEl.textContent = title || "";
if (statusText) statusText.textContent = message || "";
}
window.addEventListener("resize", resizeCanvas);
if (!loadForm) return;
loadForm.addEventListener("submit", async function (event) {
event.preventDefault();
const submitButton = loadForm.querySelector('button[type="submit"]');
if (submitButton) {
submitButton.disabled = true;
}
startLoader("Загрузка данных матча", "Подключение к сайту...");
try {
const response = await fetch(loadForm.action, {
method: "POST",
headers: {
"X-Requested-With": "XMLHttpRequest",
"Accept": "application/json"
},
credentials: "same-origin"
});
let data = {};
try {
data = await response.json();
} catch (_) {}
if (!response.ok || !data.success) {
throw new Error(data.error || "Не удалось загрузить данные с сайта");
}
setLoaderState(
"is-success",
"Загрузка завершена",
(data.message || "Данные успешно загружены") + "\nПереход во вкладку «Игра»..."
);
setTimeout(() => {
window.location.href = data.redirect_url || "{{ '/admin/session/' ~ session[3] ~ '?tab=game' }}";
}, 1200);
} catch (error) {
setLoaderState(
"is-error",
"Ошибка загрузки",
(error.message || "Не удалось загрузить данные с сайта") + "\nВозврат во вкладку «Игра» через 3 секунды..."
);
setTimeout(() => {
window.location.href = "{{ '/admin/session/' ~ session[3] ~ '?tab=game' }}";
}, 3000);
} finally {
if (submitButton) {
submitButton.disabled = false;
}
}
});
})();
</script>
</body> </body>
</html> </html>

View File

@@ -298,6 +298,70 @@
color: var(--text); color: var(--text);
} }
/* ===== MATRIX LOADER ===== */
.matrix-loader {
position: fixed;
inset: 0;
z-index: 9999;
display: none;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.92);
}
.matrix-loader.active {
display: flex;
}
.matrix-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.matrix-content {
position: relative;
z-index: 2;
text-align: center;
padding: 24px 28px;
border-radius: 16px;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(0, 255, 120, 0.25);
}
.matrix-title {
font-size: 26px;
color: #9dffb2;
}
.matrix-status {
font-size: 14px;
color: #d7ffe1;
}
.matrix-loader.is-error .matrix-content {
border-color: rgba(255, 90, 90, 0.35);
box-shadow: 0 0 30px rgba(255, 90, 90, 0.12);
}
.matrix-loader.is-error .matrix-title {
color: #ff9c9c;
text-shadow: 0 0 10px rgba(255, 90, 90, 0.25);
}
.matrix-loader.is-error .matrix-status {
color: #ffd4d4;
}
.matrix-loader.is-success .matrix-title {
color: #9dffb2;
}
.matrix-status {
white-space: pre-wrap;
word-break: break-word;
}
</style> </style>
</head> </head>
<body> <body>
@@ -345,6 +409,21 @@
</select> </select>
</div> </div>
<div class="filter-group">
<label>Статус матчей</label>
<div class="check-line">
<input type="hidden" name="hide_finished" value="false">
<input
type="checkbox"
name="hide_finished"
value="true"
{% if hide_finished %}checked{% endif %}
onchange="document.getElementById('filters-form').submit();"
>
<span>Завершенные матчи</span>
</div>
</div>
<div class="filter-group"> <div class="filter-group">
<label>&nbsp;</label> <label>&nbsp;</label>
<div class="check-line"> <div class="check-line">
@@ -364,7 +443,6 @@
<th>Тур</th> <th>Тур</th>
<th>Сезон</th> <th>Сезон</th>
<th>Статус</th> <th>Статус</th>
<th>Оператор</th>
<th>Действие</th> <th>Действие</th>
</tr> </tr>
</thead> </thead>
@@ -386,14 +464,8 @@
{{ status }} {{ status }}
</span> </span>
</td> </td>
<td colspan="2"> <td>
<form method="get" action="/admin/matches/{{ row[0] }}/select" class="select-form js-select-form"> <form method="get" action="/admin/matches/{{ row[0] }}/select" class="select-form js-select-form">
<input
type="text"
name="operator_name"
placeholder="Имя оператора"
class="operator-input"
>
<button type="submit" class="btn">Выбрать матч</button> <button type="submit" class="btn">Выбрать матч</button>
</form> </form>
</td> </td>
@@ -406,8 +478,94 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<script> <div id="matrixLoader" class="matrix-loader" aria-hidden="true">
<canvas id="matrixCanvas" class="matrix-canvas"></canvas>
<div class="matrix-content">
<div class="matrix-title">Подготовка матча</div>
<div class="matrix-status" id="matrixStatusText">Инициализация...</div>
</div>
</div>
<script>
(function () { (function () {
const loader = document.getElementById("matrixLoader");
const titleEl = document.getElementById("matrixTitle");
const statusText = document.getElementById("matrixStatusText");
const canvas = document.getElementById("matrixCanvas");
const ctx = canvas ? canvas.getContext("2d") : null;
let matrixAnimationId = null;
let drops = [];
let fontSize = 16;
let columns = 0;
let lastTime = 0;
const fps = 18;
const interval = 1000 / fps;
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZАБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ@$%#*+=<>";
function resizeCanvas() {
if (!canvas || !ctx) return;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
columns = Math.floor(canvas.width / fontSize);
drops = Array(columns).fill(1);
}
function drawMatrix(time = 0) {
if (!canvas || !ctx) return;
if (time - lastTime < interval) {
matrixAnimationId = requestAnimationFrame(drawMatrix);
return;
}
lastTime = time;
ctx.fillStyle = "rgba(0, 0, 0, 0.08)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#00ff66";
ctx.font = `${fontSize}px monospace`;
for (let i = 0; i < drops.length; i++) {
const text = chars[Math.floor(Math.random() * chars.length)];
ctx.fillText(text, i * fontSize, drops[i] * fontSize);
if (drops[i] * fontSize > canvas.height && Math.random() > 0.975) {
drops[i] = 0;
}
drops[i] ++;
}
matrixAnimationId = requestAnimationFrame(drawMatrix);
}
function startLoader(title, message) {
if (loader) {
loader.classList.add("active");
loader.classList.remove("is-error", "is-success");
}
if (titleEl) titleEl.textContent = title || "Подготовка матча";
if (statusText) statusText.textContent = message || "Инициализация...";
document.body.style.overflow = "hidden";
resizeCanvas();
if (!matrixAnimationId) {
drawMatrix();
}
}
function setLoaderState(state, title, message) {
if (loader) {
loader.classList.remove("is-error", "is-success");
if (state) loader.classList.add(state);
}
if (titleEl) titleEl.textContent = title || "";
if (statusText) statusText.textContent = message || "";
}
window.addEventListener("resize", resizeCanvas);
async function handleSelectSubmit(event) { async function handleSelectSubmit(event) {
const form = event.target; const form = event.target;
if (!form.classList.contains("js-select-form")) return; if (!form.classList.contains("js-select-form")) return;
@@ -415,17 +573,16 @@
event.preventDefault(); event.preventDefault();
const submitButton = form.querySelector('button[type="submit"]'); const submitButton = form.querySelector('button[type="submit"]');
const operatorInput = form.querySelector('input[name="operator_name"]');
const operatorName = operatorInput ? operatorInput.value : "";
if (submitButton) { if (submitButton) {
submitButton.disabled = true; submitButton.disabled = true;
submitButton.textContent = "Подготовка..."; submitButton.textContent = "Подготовка...";
} }
startLoader("Подготовка матча", "Создание игровой сессии...");
try { try {
const url = new URL(form.action, window.location.origin); const url = new URL(form.action, window.location.origin);
url.searchParams.set("operator_name", operatorName);
const response = await fetch(url.toString(), { const response = await fetch(url.toString(), {
method: "GET", method: "GET",
@@ -436,13 +593,21 @@
credentials: "same-origin" credentials: "same-origin"
}); });
const data = await response.json(); let data = {};
try {
data = await response.json();
} catch (_) {}
if (!response.ok || !data.success) { if (!response.ok || !data.success) {
throw new Error(data.error || data.vmix_error || "Не удалось выбрать матч"); throw new Error(data.error || data.vmix_error || "Не удалось выбрать матч");
} }
if (data.download_url) { if (data.download_url) {
setLoaderState("",
"Подготовка матча",
"Формирование проекта vMix..."
);
const downloadResponse = await fetch(data.download_url, { const downloadResponse = await fetch(data.download_url, {
method: "GET", method: "GET",
credentials: "same-origin" credentials: "same-origin"
@@ -457,6 +622,11 @@
throw new Error(errText); throw new Error(errText);
} }
setLoaderState("",
"Подготовка матча",
"Скачивание проекта vMix..."
);
const blob = await downloadResponse.blob(); const blob = await downloadResponse.blob();
let filename = "project.vmix"; let filename = "project.vmix";
@@ -481,13 +651,42 @@
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
a.remove(); a.remove();
window.URL.revokeObjectURL(blobUrl);
setLoaderState(
"is-success",
"Загрузка завершена",
"Проект vMix успешно подготовлен.\nПереход во вкладку «Игра»..."
);
setTimeout(() => {
window.URL.revokeObjectURL(blobUrl);
window.location.href = data.session_url + "?tab=game";
}, 1200);
return;
} }
window.location.href = data.session_url; setLoaderState(
"is-success",
"Загрузка завершена",
"Матч подготовлен.\nПереход во вкладку «Игра»..."
);
setTimeout(() => {
window.location.href = data.session_url + "?tab=game";
}, 1200);
} catch (error) { } catch (error) {
alert(error.message || "Ошибка при подготовке матча"); setLoaderState(
"is-error",
"Ошибка подготовки",
(error.message || "Ошибка при подготовке матча") + "\nВозврат к списку матчей через 3 секунды..."
);
setTimeout(() => {
window.location.href = "/admin/matches";
}, 3000);
} finally {
if (submitButton) { if (submitButton) {
submitButton.disabled = false; submitButton.disabled = false;
submitButton.textContent = "Выбрать матч"; submitButton.textContent = "Выбрать матч";
@@ -497,6 +696,7 @@
document.addEventListener("submit", handleSelectSubmit); document.addEventListener("submit", handleSelectSubmit);
})(); })();
(function () { (function () {
const LOGIN_URL = "/login"; const LOGIN_URL = "/login";
const IDLE_LIMIT_MS = 2 * 60 * 60 * 1000; const IDLE_LIMIT_MS = 2 * 60 * 60 * 1000;