1. добавил в админку сортировку игроков и Фото (если галочка стоит, то путь к фото генерируется автоматически)

2. фото обновляются прям в json
This commit is contained in:
2026-06-22 15:31:07 +03:00
parent cd370cf04f
commit f63061a06d
10 changed files with 620 additions and 98 deletions

121
app.py
View File

@@ -23,7 +23,7 @@ from pathlib import Path
import io
import asyncio
import time
from urllib.parse import quote
from urllib.parse import quote, urlencode
import traceback
import contextlib
import os
@@ -60,6 +60,8 @@ from repositories.player_repository import (
search_players_for_admin,
get_player_by_id,
update_player_admin,
update_player_photo_enabled,
ensure_player_photo_enabled_column,
)
from repositories.referee_repository import (
@@ -117,6 +119,8 @@ from repositories.match_lineup_repository import (
)
from services.vmix_json_service import (
EMPTY_PHOTO_PATH,
build_generated_player_photo_path,
build_lineup_json,
get_vmix_match_info_by_token,
get_vmix_standings,
@@ -124,6 +128,7 @@ from services.vmix_json_service import (
get_vmix_team_formations,
get_vmix_scoreboard_info,
get_vmix_players_goal,
resolve_player_photo_for_json,
)
from scheduler import run_scheduler
@@ -139,10 +144,50 @@ templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
PLAYER_ADMIN_SORT_COLUMNS = [
"id",
"full_name",
"first_name",
"last_name",
"external_id",
"position",
"team_name",
"photo",
]
def build_player_admin_sort_links(q: str, current_sort: str, current_direction: str) -> dict:
links = {}
current_sort = current_sort if current_sort in PLAYER_ADMIN_SORT_COLUMNS else "id"
current_direction = "asc" if current_direction == "asc" else "desc"
for column in PLAYER_ADMIN_SORT_COLUMNS:
next_direction = (
"desc"
if current_sort == column and current_direction == "asc"
else "asc"
)
indicator = ""
if current_sort == column:
indicator = "" if current_direction == "asc" else ""
links[column] = {
"url": f"/admin/db/players?{urlencode({'q': q or '', 'sort': column, 'direction': next_direction})}",
"indicator": indicator,
}
return links
@app.on_event("startup")
def start_scheduler():
import threading
try:
ensure_player_photo_enabled_column()
except Exception:
traceback.print_exc()
thread = threading.Thread(
target=run_scheduler, kwargs={"with_signal_handlers": False}, daemon=True
)
@@ -370,6 +415,12 @@ async def admin_auth_middleware(request: Request, call_next):
}
response = await call_next(request)
if path.startswith("/vmix/session/"):
response.headers["Cache-Control"] = "no-store, no-cache, max-age=0, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
@@ -803,23 +854,57 @@ def admin_db_index(request: Request):
@app.get("/admin/db/players", response_class=HTMLResponse)
def admin_db_players(request: Request, q: str = Query(default="")):
def admin_db_players(
request: Request,
q: str = Query(default=""),
sort: str = Query(default="id"),
direction: str = Query(default="desc"),
):
# denied = require_role(request, {"admin"})
# if denied:
# return denied
players = search_players_for_admin(q)
allowed_sort = set(PLAYER_ADMIN_SORT_COLUMNS)
if sort not in allowed_sort:
sort = "id"
direction = "asc" if direction == "asc" else "desc"
players = search_players_for_admin(q, sort_by=sort, sort_dir=direction)
return templates.TemplateResponse(
name="admin_db_players.html",
request=request,
context={
"q": q,
"sort": sort,
"direction": direction,
"sort_links": build_player_admin_sort_links(q, sort, direction),
"players": players,
},
)
@app.post("/admin/db/players/{player_id}/photo-enabled")
def admin_db_player_photo_enabled_submit(
player_id: int,
q: str = Form(default=""),
sort: str = Form(default="id"),
direction: str = Form(default="desc"),
photo_enabled: bool = Form(default=False),
):
allowed_sort = set(PLAYER_ADMIN_SORT_COLUMNS)
if sort not in allowed_sort:
sort = "id"
direction = "asc" if direction == "asc" else "desc"
update_player_photo_enabled(player_id=player_id, photo_enabled=photo_enabled)
return RedirectResponse(
url=f"/admin/db/players?{urlencode({'q': q or '', 'sort': sort, 'direction': direction})}",
status_code=303,
)
@app.get("/admin/db/players/{player_id}/edit", response_class=HTMLResponse)
def admin_db_player_edit(request: Request, player_id: int):
player = get_player_by_id(player_id)
@@ -846,6 +931,7 @@ def admin_db_player_edit_submit(
birth_date: str = Form(default=""),
photo: str = Form(default=""),
video: str = Form(default=""),
photo_enabled: bool = Form(default=False),
):
update_player_admin(
player_id=player_id,
@@ -857,6 +943,7 @@ def admin_db_player_edit_submit(
birth_date=birth_date,
photo=photo,
video=video,
photo_enabled=photo_enabled,
)
return RedirectResponse(
@@ -1358,7 +1445,7 @@ EMPTY_PLAYER = {
"pos": " ",
"number_lastname_amp_K": " ",
"number_fullname": " ",
"photo": r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo\EMPTY.png",
"photo": EMPTY_PHOTO_PATH,
}
@@ -1586,7 +1673,7 @@ EMPTY_PLAYER_FORMATION = {
"number": " ",
"number_lastname_amp_K": " ",
"position": " ",
"photo": r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo\EMPTY.png",
"photo": EMPTY_PHOTO_PATH,
}
@@ -1613,13 +1700,23 @@ def build_vmix_formation_response(rows, team_id, team_name):
"number_lastname_amp_K": f"{number} {lastname}{suffix_str}".strip(),
"number": number,
"position": position,
"photo": (
r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo"
+ "\\"
+ team_name
+ "\\"
+ (p[0] + " " + p[4]).strip()
+ ".png"
"photo": resolve_player_photo_for_json(
{
"player_id": p[7] if len(p) > 7 else None,
"team_id": p[8] if len(p) > 8 else team_id,
"player_name": p[9] if len(p) > 9 else "",
"last_name": lastname,
"first_name": p[4] if len(p) > 4 else "",
"number": number,
"position": position,
"photo_enabled": p[6] if len(p) > 6 else False,
},
generated_photo=build_generated_player_photo_path(
team_name=team_name,
last_name=lastname,
first_name=p[4] if len(p) > 4 else "",
),
fallback_team_id=team_id,
),
}
)

View File

@@ -230,9 +230,12 @@ def get_match_lineup_for_vmix(
COALESCE(mlp.number::text, '') AS number,
COALESCE(p.position, '') AS position,
COALESCE(mlp.is_captain, FALSE) AS is_captain,
COALESCE(p.position, '') AS pos
COALESCE(p.position, '') AS pos,
p.photo,
COALESCE(p.photo_enabled, FALSE) AS photo_enabled,
CASE WHEN mlp.side = 'home' THEN %s ELSE %s END AS team_id
FROM match_lineup_players mlp
JOIN players p
LEFT JOIN players p
ON p.id = mlp.player_id
WHERE mlp.match_id = %s
ORDER BY
@@ -248,7 +251,7 @@ def get_match_lineup_for_vmix(
COALESCE(p.first_name, ''),
p.id
""",
(match_id,),
(home_team_id, away_team_id, match_id),
)
player_rows = cur.fetchall()
@@ -293,6 +296,9 @@ def get_match_lineup_for_vmix(
"position": row[8] or "",
"is_captain": bool(row[9]),
"pos": row[10] or "",
"photo": row[11] or "",
"photo_enabled": bool(row[12]),
"team_id": row[13],
}
side = row[0]

View File

@@ -13,7 +13,9 @@ def get_match_lineups_grouped(match_id: int, home_team_id: int, away_team_id: in
ml.is_captain,
p.last_name,
p.first_name,
p.position as pos
p.position as pos,
p.photo,
COALESCE(p.photo_enabled, FALSE) AS photo_enabled
FROM match_lineups ml
LEFT JOIN players p ON p.id = ml.player_id
WHERE ml.match_id = %s;
@@ -46,10 +48,13 @@ def get_match_lineups_grouped(match_id: int, home_team_id: int, away_team_id: in
last_name,
first_name,
pos,
photo,
photo_enabled,
) = row
item = {
"player_id": player_id,
"team_id": team_id,
"number": number or "",
"last_name": last_name or "",
"first_name": first_name or "",
@@ -57,6 +62,8 @@ def get_match_lineups_grouped(match_id: int, home_team_id: int, away_team_id: in
"position": position or "",
"is_captain": bool(is_captain),
"pos": pos,
"photo": photo or "",
"photo_enabled": bool(photo_enabled),
}
if team_id == home_team_id and lineup_type == "starting":

View File

@@ -222,50 +222,91 @@ def get_player_id_by_external_id(external_id: str) -> int | None:
conn.close()
from db import get_connection
PLAYER_ADMIN_SORT_COLUMNS = {
"id": "p.id",
"full_name": "p.full_name",
"first_name": "p.first_name",
"last_name": "p.last_name",
"external_id": "p.external_id",
"position": "p.position",
"team_name": "t.name",
"photo": "p.photo_enabled",
}
def search_players_for_admin(q: str = "") -> list[dict]:
def ensure_player_photo_enabled_column() -> None:
"""Добавляет флаг использования фото игрока, если база ещё не обновлена."""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
ALTER TABLE players
ADD COLUMN IF NOT EXISTS photo_enabled BOOLEAN NOT NULL DEFAULT FALSE;
"""
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def _normalize_player_sort(sort_by: str = "id", sort_dir: str = "desc") -> tuple[str, str, str, str]:
sort_by = (sort_by or "id").strip()
if sort_by not in PLAYER_ADMIN_SORT_COLUMNS:
sort_by = "id"
sort_dir = (sort_dir or "desc").strip().lower()
if sort_dir not in {"asc", "desc"}:
sort_dir = "desc"
return sort_by, sort_dir, PLAYER_ADMIN_SORT_COLUMNS[sort_by], sort_dir.upper()
def search_players_for_admin(q: str = "", sort_by: str = "id", sort_dir: str = "desc") -> list[dict]:
sort_by, sort_dir, order_column, order_direction = _normalize_player_sort(sort_by, sort_dir)
limit = 200
conn = get_connection()
try:
with conn.cursor() as cur:
params: list = []
where_sql = ""
if q.strip():
pattern = f"%{q.strip()}%"
cur.execute(
"""
SELECT
p.id,
p.full_name,
p.first_name,
p.last_name,
p.external_id,
p.position
FROM players p
where_sql = """
WHERE
p.full_name ILIKE %s
OR p.first_name ILIKE %s
OR p.last_name ILIKE %s
OR COALESCE(p.external_id, '') ILIKE %s
ORDER BY p.full_name ASC, p.id ASC
LIMIT 200
""",
(pattern, pattern, pattern, pattern),
)
else:
cur.execute(
"""
SELECT
p.id,
p.full_name,
p.first_name,
p.last_name,
p.external_id,
p.position
FROM players p
ORDER BY p.id DESC
LIMIT 200
"""
)
OR COALESCE(t.name, '') ILIKE %s
"""
params.extend([pattern, pattern, pattern, pattern, pattern])
params.append(limit)
cur.execute(
f"""
SELECT
p.id,
p.full_name,
p.first_name,
p.last_name,
p.external_id,
p.position,
COALESCE(t.name, '') AS team_name,
COALESCE(p.photo_enabled, FALSE) AS photo_enabled
FROM players p
LEFT JOIN teams t ON t.id = p.team_id
{where_sql}
ORDER BY {order_column} {order_direction} NULLS LAST, p.id ASC
LIMIT %s
""",
tuple(params),
)
rows = cur.fetchall()
@@ -277,6 +318,8 @@ def search_players_for_admin(q: str = "") -> list[dict]:
"last_name": row[3] or "",
"external_id": row[4] or "",
"position": row[5] or "",
"team_name": row[6] or "",
"photo_enabled": bool(row[7]),
}
for row in rows
]
@@ -299,8 +342,11 @@ def get_player_by_id(player_id: int) -> dict | None:
p.position,
p.born,
p.photo,
p.video
p.video,
COALESCE(t.name, '') AS team_name,
COALESCE(p.photo_enabled, FALSE) AS photo_enabled
FROM players p
LEFT JOIN teams t ON t.id = p.team_id
WHERE p.id = %s
LIMIT 1
""",
@@ -321,6 +367,8 @@ def get_player_by_id(player_id: int) -> dict | None:
"birth_date": normalize_birth_date_for_input(row[6]),
"photo": row[7] or "",
"video": row[8] or "",
"team_name": row[9] or "",
"photo_enabled": bool(row[10]),
}
finally:
conn.close()
@@ -336,6 +384,7 @@ def update_player_admin(
birth_date: str = "",
photo: str = "",
video: str = "",
photo_enabled: bool = False,
) -> None:
conn = get_connection()
try:
@@ -351,7 +400,9 @@ def update_player_admin(
position = %s,
born = NULLIF(%s, '')::date,
photo = NULLIF(%s, ''),
video = NULLIF(%s, '')
video = NULLIF(%s, ''),
photo_enabled = %s,
updated_at = NOW()
WHERE id = %s
""",
(
@@ -363,6 +414,7 @@ def update_player_admin(
birth_date.strip(),
photo.strip(),
video.strip(),
bool(photo_enabled),
player_id,
),
)
@@ -372,3 +424,25 @@ def update_player_admin(
raise
finally:
conn.close()
def update_player_photo_enabled(player_id: int, photo_enabled: bool = False) -> None:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE players
SET
photo_enabled = %s,
updated_at = NOW()
WHERE id = %s
""",
(bool(photo_enabled), player_id),
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()

View File

@@ -3,6 +3,232 @@ from db import get_connection
from repositories.match_lineup_repository import get_match_lineup_for_vmix
PHOTO_BASE_PATH = r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo"
EMPTY_PHOTO_PATH = PHOTO_BASE_PATH + r"\EMPTY.png"
def build_generated_player_photo_path(team_name: str, last_name: str, first_name: str) -> str:
return (
PHOTO_BASE_PATH
+ "\\"
+ str(team_name or "")
+ "\\"
+ (str(last_name or "") + " " + str(first_name or "")).strip()
+ ".png"
)
def resolve_player_photo(photo_enabled: bool, generated_photo: str) -> str:
if photo_enabled:
return generated_photo
return EMPTY_PHOTO_PATH
def _normalize_text(value) -> str:
return " ".join(str(value or "").strip().lower().split())
def _player_name_variants(player: dict) -> set[str]:
first_name = _normalize_text(player.get("first_name"))
last_name = _normalize_text(player.get("last_name"))
player_name = _normalize_text(player.get("player_name"))
full_name = _normalize_text(player.get("full_name"))
variants = {player_name, full_name}
if first_name or last_name:
variants.add(f"{last_name} {first_name}".strip())
variants.add(f"{first_name} {last_name}".strip())
return {v for v in variants if v}
def _candidate_name_variants(row: tuple) -> set[str]:
_, full_name, last_name, first_name, *_ = row
full_name = _normalize_text(full_name)
last_name = _normalize_text(last_name)
first_name = _normalize_text(first_name)
variants = {full_name}
if first_name or last_name:
variants.add(f"{last_name} {first_name}".strip())
variants.add(f"{first_name} {last_name}".strip())
return {v for v in variants if v}
def _find_player_photo_state(
*,
player_id=None,
team_id=None,
player_name: str = "",
first_name: str = "",
last_name: str = "",
full_name: str = "",
number: str = "",
) -> tuple[str | None, bool] | None:
"""
Берёт актуальное состояние галочки photo_enabled прямо из players.
Если в составе нет корректного player_id, пробует найти игрока по команде, номеру и имени.
Это важно для старых/загруженных с сайта составов, где player_id мог быть пустым.
Значение p.photo здесь не используется для JSON-пути: путь генерируется по старой схеме.
"""
try:
player_id_int = int(player_id) if str(player_id or "").strip().isdigit() else None
except Exception:
player_id_int = None
try:
team_id_int = int(team_id) if str(team_id or "").strip().isdigit() else None
except Exception:
team_id_int = None
target_number = str(number or "").strip()
target_player = {
"player_name": player_name,
"first_name": first_name,
"last_name": last_name,
"full_name": full_name,
}
target_names = _player_name_variants(target_player)
where_parts = []
params = []
if player_id_int is not None:
where_parts.append("p.id = %s")
params.append(player_id_int)
if team_id_int is not None:
team_conditions = []
team_params = []
if target_number:
team_conditions.append("COALESCE(p.number::text, '') = %s")
team_params.append(target_number)
for name in sorted(target_names):
like = f"%{name}%"
team_conditions.append(
"""
(
LOWER(COALESCE(p.full_name, '')) = %s
OR LOWER(TRIM(COALESCE(p.last_name, '') || ' ' || COALESCE(p.first_name, ''))) = %s
OR LOWER(TRIM(COALESCE(p.first_name, '') || ' ' || COALESCE(p.last_name, ''))) = %s
OR LOWER(COALESCE(p.full_name, '')) LIKE %s
)
"""
)
team_params.extend([name, name, name, like])
if team_conditions:
where_parts.append(f"(p.team_id = %s AND ({' OR '.join(team_conditions)}))")
params.append(team_id_int)
params.extend(team_params)
if not where_parts:
return None
query = f"""
SELECT
p.id,
COALESCE(p.full_name, ''),
COALESCE(p.last_name, ''),
COALESCE(p.first_name, ''),
COALESCE(p.number::text, ''),
p.team_id,
p.photo,
COALESCE(p.photo_enabled, FALSE) AS photo_enabled
FROM players p
WHERE {' OR '.join(where_parts)}
LIMIT 50
"""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(query, tuple(params))
rows = cur.fetchall()
finally:
conn.close()
best_row = None
best_score = -1
for row in rows:
row_id, _, _, _, row_number, row_team_id, *_ = row
row_names = _candidate_name_variants(row)
id_match = player_id_int is not None and int(row_id) == player_id_int
team_match = team_id_int is not None and int(row_team_id) == team_id_int
number_match = bool(target_number) and str(row_number or "").strip() == target_number
name_match = bool(target_names.intersection(row_names))
partial_name_match = False
if target_names and row_names:
partial_name_match = any(
target in candidate or candidate in target
for target in target_names
for candidate in row_names
if len(target) >= 4 and len(candidate) >= 4
)
# Если ID выглядит как номер игрока и случайно совпал с чужим players.id,
# не считаем его надёжным без совпадения команды/имени/номера.
trusted_id_match = id_match and (
(team_id_int is None or team_match)
and (not target_number and not target_names or number_match or name_match or partial_name_match)
)
score = 0
if trusted_id_match:
score += 100
if team_match:
score += 40
if number_match:
score += 30
if name_match:
score += 35
elif partial_name_match:
score += 15
# Для поиска без надёжного ID нужно хотя бы совпадение команды и номера/имени.
if not trusted_id_match and not (team_match and (number_match or name_match or partial_name_match)):
continue
if score > best_score:
best_score = score
best_row = row
if not best_row:
return None
return best_row[6], bool(best_row[7])
def resolve_player_photo_for_json(
player: dict,
generated_photo: str,
fallback_team_id=None,
) -> str:
state = _find_player_photo_state(
player_id=player.get("player_id") or player.get("id"),
team_id=player.get("team_id") or fallback_team_id,
player_name=player.get("player_name") or "",
first_name=player.get("first_name") or "",
last_name=player.get("last_name") or "",
full_name=player.get("full_name") or "",
number=player.get("number") or player.get("player_number") or "",
)
if state is not None:
_photo, photo_enabled = state
return resolve_player_photo(photo_enabled=photo_enabled, generated_photo=generated_photo)
return resolve_player_photo(
photo_enabled=player.get("photo_enabled"),
generated_photo=generated_photo,
)
def build_lineup_json(match_id, home_team_id, away_team_id, name, team_a_name, team_b_name):
lineups = get_match_lineup_for_vmix(
match_id=match_id,
@@ -14,6 +240,8 @@ def build_lineup_json(match_id, home_team_id, away_team_id, name, team_a_name, t
result = []
fallback_team_id = home_team_id if str(name).startswith("home") else away_team_id
for p in players:
suffix = []
if "вратарь" in (p.get("pos") or "").lower():
@@ -40,17 +268,16 @@ def build_lineup_json(match_id, home_team_id, away_team_id, name, team_a_name, t
+ (f" {', '.join(suffix)}" if suffix else ""),
"pos": p.get("pos", ""),
"position": p.get("position", ""),
"photo": (
r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo"
+ "\\"
+ (team_a_name if "home" in name else team_b_name)
+ "\\"
+ (p.get("last_name", "")
+ " "
+ p.get("first_name", "")).strip()
+ ".png"
),
}
"photo": resolve_player_photo_for_json(
p,
generated_photo=build_generated_player_photo_path(
team_name=team_a_name if "home" in name else team_b_name,
last_name=p.get("last_name", ""),
first_name=p.get("first_name", ""),
),
fallback_team_id=fallback_team_id,
),
}
)
return {"players": result}
@@ -231,11 +458,16 @@ def get_vmix_schedule(session_token: str):
def get_vmix_team_formations(session_token: str, team_id: int):
query = """
SELECT
p.last_name,
COALESCE(NULLIF(p.last_name, ''), NULLIF(mf.player_name, ''), '') AS last_name,
mf.is_captain,
p.number,
p.position,
p.first_name
COALESCE(NULLIF(mf.number, ''), p.number::text, '') AS number,
COALESCE(NULLIF(mf.position, ''), p.position, '') AS position,
COALESCE(p.first_name, '') AS first_name,
p.photo,
COALESCE(p.photo_enabled, FALSE) AS photo_enabled,
mf.player_id,
mf.team_id,
COALESCE(mf.player_name, '') AS player_name
FROM match_formations mf
LEFT JOIN players p ON p.id = mf.player_id
WHERE mf.match_id = %s and mf.team_id = %s

View File

@@ -0,0 +1,2 @@
ALTER TABLE players
ADD COLUMN IF NOT EXISTS photo_enabled BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -143,6 +143,31 @@
object-fit: contain;
display: block;
}
input[readonly] {
color: var(--muted);
cursor: default;
}
.checkbox-row {
min-height: 42px;
display: flex;
align-items: center;
gap: 10px;
color: var(--text);
}
.checkbox-row input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--accent);
}
.hint {
color: var(--muted);
font-size: 12px;
line-height: 1.4;
}
</style>
</head>
@@ -182,6 +207,11 @@
<input type="text" name="external_id" value="{{ player.external_id }}">
</div>
<div class="form-group">
<label>Название команды</label>
<input type="text" value="{{ player.team_name }}" readonly>
</div>
<div class="form-group">
<label>Амплуа</label>
<select name="position">
@@ -201,6 +231,11 @@
<div class="form-group full">
<label>Фото игрока</label>
<input type="text" name="photo" value="{{ player.photo }}">
<label class="checkbox-row">
<input type="checkbox" name="photo_enabled" value="true" {% if player.photo_enabled %}checked{% endif %}>
Использовать фото в JSON/vMix
</label>
<div class="hint">Если галочка включена, путь к фото в JSON генерируется по старой схеме: Photo\Команда\Фамилия Имя.png. Если выключена — отдается EMPTY.png.</div>
{% if player.photo %}
<div class="media-preview-box">
<img src="{{ player.photo }}" alt="{{ player.full_name }}" class="media-preview-image">

View File

@@ -28,7 +28,7 @@
}
.page {
max-width: 1400px;
max-width: 1500px;
margin: 0 auto;
padding: 24px;
}
@@ -62,7 +62,7 @@
}
input[type="text"] {
min-width: 280px;
min-width: 320px;
min-height: 42px;
padding: 0 12px;
border-radius: 10px;
@@ -94,11 +94,16 @@
border: 1px solid var(--border);
}
.table-wrap {
width: 100%;
overflow-x: auto;
border-radius: 12px;
}
table {
width: 100%;
border-collapse: collapse;
background: var(--panel-2);
border-radius: 12px;
overflow: hidden;
}
@@ -107,6 +112,7 @@
border-bottom: 1px solid var(--border);
text-align: left;
font-size: 14px;
white-space: nowrap;
}
th {
@@ -118,10 +124,47 @@
border-bottom: none;
}
.sort-link {
color: var(--muted);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 6px;
}
.sort-link:hover {
color: var(--text);
}
.sort-indicator {
color: var(--accent);
min-width: 12px;
display: inline-block;
}
.id-col {
width: 70px;
}
.photo-col {
width: 80px;
text-align: center;
}
.photo-check {
width: 18px;
height: 18px;
accent-color: var(--accent);
cursor: pointer;
}
.photo-toggle-form {
margin: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.action-col {
width: 140px;
text-align: right;
@@ -143,45 +186,68 @@
<div class="title">Игроки</div>
<form method="get" action="/admin/db/players" class="search-form">
<input type="text" name="q" value="{{ q }}" placeholder="Поиск по ФИО или external_id">
<input type="hidden" name="sort" value="{{ sort }}">
<input type="hidden" name="direction" value="{{ direction }}">
<input type="text" name="q" value="{{ q }}" placeholder="Поиск по ФИО, external_id или команде">
<button type="submit" class="btn btn-primary">Найти</button>
<a href="/admin/db" class="btn btn-secondary">Назад</a>
</form>
</div>
{% if players %}
<table>
<thead>
<tr>
<th class="id-col">ID</th>
<th>ФИО</th>
<th>Имя</th>
<th>Фамилия</th>
<th>External ID</th>
<th>Амплуа</th>
<th class="action-col"></th>
</tr>
</thead>
<tbody>
{% for p in players %}
<tr>
<td>{{ p.id }}</td>
<td>{{ p.full_name }}</td>
<td>{{ p.first_name }}</td>
<td>{{ p.last_name }}</td>
<td>{{ p.external_id }}</td>
<td>{{ p.position }}</td>
<td class="action-col">
<a href="/admin/db/players/{{ p.id }}/edit" class="btn btn-secondary">Редактировать</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="id-col"><a class="sort-link" href="{{ sort_links.id.url }}">ID <span class="sort-indicator">{{ sort_links.id.indicator }}</span></a></th>
<th><a class="sort-link" href="{{ sort_links.full_name.url }}">ФИО <span class="sort-indicator">{{ sort_links.full_name.indicator }}</span></a></th>
<th><a class="sort-link" href="{{ sort_links.first_name.url }}">Имя <span class="sort-indicator">{{ sort_links.first_name.indicator }}</span></a></th>
<th><a class="sort-link" href="{{ sort_links.last_name.url }}">Фамилия <span class="sort-indicator">{{ sort_links.last_name.indicator }}</span></a></th>
<th><a class="sort-link" href="{{ sort_links.external_id.url }}">External ID <span class="sort-indicator">{{ sort_links.external_id.indicator }}</span></a></th>
<th><a class="sort-link" href="{{ sort_links.position.url }}">Амплуа <span class="sort-indicator">{{ sort_links.position.indicator }}</span></a></th>
<th><a class="sort-link" href="{{ sort_links.team_name.url }}">Название команды <span class="sort-indicator">{{ sort_links.team_name.indicator }}</span></a></th>
<th class="photo-col"><a class="sort-link" href="{{ sort_links.photo.url }}">Фото <span class="sort-indicator">{{ sort_links.photo.indicator }}</span></a></th>
<th class="action-col"></th>
</tr>
</thead>
<tbody>
{% for p in players %}
<tr>
<td>{{ p.id }}</td>
<td>{{ p.full_name }}</td>
<td>{{ p.first_name }}</td>
<td>{{ p.last_name }}</td>
<td>{{ p.external_id }}</td>
<td>{{ p.position }}</td>
<td>{{ p.team_name }}</td>
<td class="photo-col">
<form method="post" action="/admin/db/players/{{ p.id }}/photo-enabled" class="photo-toggle-form">
<input type="hidden" name="q" value="{{ q }}">
<input type="hidden" name="sort" value="{{ sort }}">
<input type="hidden" name="direction" value="{{ direction }}">
<input
type="checkbox"
name="photo_enabled"
value="true"
class="photo-check"
title="Использовать фото игрока в JSON/vMix"
onchange="this.form.submit()"
{% if p.photo_enabled %}checked{% endif %}
>
</form>
</td>
<td class="action-col">
<a href="/admin/db/players/{{ p.id }}/edit" class="btn btn-secondary">Редактировать</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-box">Игроки не найдены.</div>
{% endif %}
</div>
</div>
</body>
</html>
</html>

BIN
vmix.zip

Binary file not shown.

View File

@@ -27,6 +27,9 @@ CREATE TABLE players (
number VARCHAR(20),
position VARCHAR(50),
birth_date DATE,
photo TEXT,
photo_enabled BOOLEAN NOT NULL DEFAULT FALSE,
video TEXT,
height_cm INTEGER,
weight_kg INTEGER,
is_active BOOLEAN NOT NULL DEFAULT TRUE,