Compare commits
3 Commits
ffdaef5051
...
4ce5faed59
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ce5faed59 | |||
| a20f80453b | |||
| 7baed89b6a |
4
agent.py
4
agent.py
@@ -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
|
||||||
|
|||||||
60
app.py
60
app.py
@@ -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,6 +476,7 @@ 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),
|
||||||
@@ -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,14 +628,24 @@ 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]
|
||||||
|
|
||||||
|
try:
|
||||||
run_parser_game(str(match_external_id))
|
run_parser_game(str(match_external_id))
|
||||||
|
|
||||||
# сброс ручного состава и тренеров, чтобы подтянулись свежие данные парсера
|
# сброс ручного состава и тренеров, чтобы подтянулись свежие данные парсера
|
||||||
@@ -644,7 +662,37 @@ def load_match_data(session_token: str):
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
return RedirectResponse(url=f"/admin/session/{session_token}", status_code=303)
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -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()
|
||||||
Binary file not shown.
@@ -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()
|
||||||
|
|||||||
@@ -2175,3 +2175,69 @@ body:not(.edit-mode-on) .referee-search {
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
padding: 24px;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 420px;
|
height: 100%;
|
||||||
background: var(--panel);
|
overflow: hidden;
|
||||||
border: 1px solid var(--border);
|
background: var(--bg);
|
||||||
border-radius: var(--radius);
|
color: var(--green);
|
||||||
box-shadow: var(--shadow);
|
font-family: "Courier New", monospace;
|
||||||
padding: 28px;
|
|
||||||
}
|
}
|
||||||
h1 { margin: 0 0 8px; font-size: 28px; }
|
|
||||||
.subtitle { color: var(--muted); margin-bottom: 22px; }
|
body {
|
||||||
.alert {
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
#matrixCanvas {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 0;
|
||||||
|
opacity: 0.22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screen {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
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);
|
||||||
border-radius: 12px;
|
background: linear-gradient(
|
||||||
padding: 12px 14px;
|
180deg,
|
||||||
margin-bottom: 16px;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--green);
|
||||||
|
box-shadow: 0 0 8px rgba(0, 255, 102, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
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%;
|
|
||||||
min-height: 44px;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: var(--panel-2);
|
|
||||||
color: var(--text);
|
|
||||||
outline: none;
|
|
||||||
}
|
}
|
||||||
input:focus {
|
|
||||||
border-color: var(--accent);
|
|
||||||
box-shadow: 0 0 0 3px rgba(79, 140, 255, 0.15);
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 46px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--accent);
|
|
||||||
color: white;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
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>
|
<div class="screen">
|
||||||
|
<div class="terminal">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div class="terminal-body" id="terminalBody">
|
||||||
|
<div class="term-line" id="welcomeLine"></div>
|
||||||
|
|
||||||
|
<div class="term-line hidden" id="loginLine">
|
||||||
|
<span id="loginPrompt" class="prompt"></span>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div class="term-line hidden" id="passwordLine">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div class="term-line hidden subtle" id="submitLine">
|
||||||
|
authorizing...
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if message %}
|
{% if message %}
|
||||||
<div class="alert alert-info">{{ message }}</div>
|
<div class="sys-message">{{ message }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<div class="alert alert-error">{{ error }}</div>
|
<div class="sys-message sys-error">{{ error }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form method="post" action="/login">
|
<form id="realLoginForm" method="post" action="/login" class="hidden">
|
||||||
<label for="username">Логин</label>
|
<input type="hidden" name="username" id="realUsername">
|
||||||
<input id="username" name="username" type="text" autocomplete="username" required>
|
<input type="hidden" name="password" id="realPassword">
|
||||||
|
|
||||||
<label for="password">Пароль</label>
|
|
||||||
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
|
||||||
|
|
||||||
<button type="submit">Войти</button>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="hint">Сессия автоматически завершится после 2 часов без активности.</div>
|
<script>
|
||||||
</div>
|
(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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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> </label>
|
<label> </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();
|
||||||
|
|
||||||
|
setLoaderState(
|
||||||
|
"is-success",
|
||||||
|
"Загрузка завершена",
|
||||||
|
"Проект vMix успешно подготовлен.\nПереход во вкладку «Игра»..."
|
||||||
|
);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
window.URL.revokeObjectURL(blobUrl);
|
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user