build 122 - стартовые пятерки

This commit is contained in:
2026-08-26 14:56:15 +03:00
parent 41ca064b3b
commit fe164c17f2
6 changed files with 529 additions and 22 deletions

2
app.py
View File

@@ -29,7 +29,7 @@ from ui_builder import install_ui_builder
from khl_site.khl_data_center import APP as khl_site_app from khl_site.khl_data_center import APP as khl_site_app
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
BUILD_VERSION = "2026.08.26.8" BUILD_VERSION = "2026.08.26.9"
# compatibility: BUILD_VERSION = "2026.08.26.1" # compatibility: BUILD_VERSION = "2026.08.26.1"
# compatibility: BUILD_VERSION = "2026.08.25.1" # compatibility: BUILD_VERSION = "2026.08.25.1"
# compatibility: BUILD_VERSION = "2026.08.24.15" # compatibility: BUILD_VERSION = "2026.08.24.15"

View File

@@ -683,6 +683,12 @@ class GameRoster(Base):
position_ru: Mapped[str] = mapped_column(String(64), nullable=False, default="") position_ru: Mapped[str] = mapped_column(String(64), nullable=False, default="")
position_en: Mapped[str] = mapped_column(String(64), nullable=False, default="") position_en: Mapped[str] = mapped_column(String(64), nullable=False, default="")
line_number: Mapped[str] = mapped_column(String(16), nullable=False, default="") line_number: Mapped[str] = mapped_column(String(16), nullable=False, default="")
# BUILD122: the API line stays available as a baseline, while operators may
# override the effective line/order without a later Stat2TV refresh erasing it.
api_line_number: Mapped[str] = mapped_column(String(16), nullable=False, default="")
api_line_sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
line_sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
line_manual: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
captain_role: Mapped[str] = mapped_column(String(32), nullable=False, default="") captain_role: Mapped[str] = mapped_column(String(32), nullable=False, default="")
lineup_status: Mapped[str] = mapped_column(String(64), nullable=False, default="") lineup_status: Mapped[str] = mapped_column(String(64), nullable=False, default="")
raw_payload: Mapped[str] = mapped_column(Text, nullable=False, default="") raw_payload: Mapped[str] = mapped_column(Text, nullable=False, default="")

View File

@@ -123,6 +123,23 @@ class GameScorePayload(BaseModel):
language: str = Field(default="ru", pattern="^(ru|en)$") language: str = Field(default="ru", pattern="^(ru|en)$")
class GameLineAssignmentPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
player_external_id: str = Field(min_length=1, max_length=64)
line_number: str = Field(default="", max_length=16)
sort_order: int = Field(default=0, ge=0, le=999)
class GameLinesPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
side: str = Field(pattern="^(home|away)$")
assignments: list[GameLineAssignmentPayload] = Field(default_factory=list)
reset: bool = False
language: str = Field(default="ru", pattern="^(ru|en)$")
class ShootoutSetupPayload(BaseModel): class ShootoutSetupPayload(BaseModel):
initial_attempts: int = Field(default=3) initial_attempts: int = Field(default=3)
reset: bool = False reset: bool = False
@@ -1030,6 +1047,30 @@ def create_hockey_router(
except ValueError as error: except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error raise HTTPException(status_code=400, detail=str(error)) from error
@router.put("/games/{external_id}/lines")
async def update_game_lines(
external_id: str,
payload: GameLinesPayload,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
try:
if payload.reset:
return service.reset_game_lines(
external_id,
side=payload.side,
updated_by=user.id,
language=payload.language,
)
return service.save_game_lines(
external_id,
side=payload.side,
assignments=[item.model_dump() for item in payload.assignments],
updated_by=user.id,
language=payload.language,
)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.put("/games/{external_id}/control/values") @router.put("/games/{external_id}/control/values")
async def update_game_values( async def update_game_values(
external_id: str, external_id: str,

View File

@@ -3050,6 +3050,7 @@ class HockeyDataService:
for row in existing_rosters for row in existing_rosters
} }
seen_rosters: set[tuple[str, str]] = set() seen_rosters: set[tuple[str, str]] = set()
api_line_order: dict[tuple[str, str], int] = {}
for entry in details.rosters: for entry in details.rosters:
profile = player_profiles.get(entry.player_external_id) profile = player_profiles.get(entry.player_external_id)
if profile is None: if profile is None:
@@ -3080,7 +3081,14 @@ class HockeyDataService:
roster.role = entry.role roster.role = entry.role
roster.position_ru = entry.position_ru roster.position_ru = entry.position_ru
roster.position_en = entry.position_en roster.position_en = entry.position_en
roster.line_number = entry.line_number api_line = str(entry.line_number or "").strip()
api_order_key = (entry.side, api_line)
api_line_order[api_order_key] = api_line_order.get(api_order_key, 0) + 1
roster.api_line_number = api_line
roster.api_line_sort_order = api_line_order[api_order_key]
if not bool(roster.line_manual):
roster.line_number = api_line
roster.line_sort_order = roster.api_line_sort_order
roster.captain_role = entry.captain_role roster.captain_role = entry.captain_role
roster.lineup_status = entry.lineup_status roster.lineup_status = entry.lineup_status
roster.raw_payload = entry.raw_payload roster.raw_payload = entry.raw_payload
@@ -4917,6 +4925,10 @@ class HockeyDataService:
), ),
"role": roster.role, "role": roster.role,
"line": roster.line_number, "line": roster.line_number,
"line_sort_order": int(roster.line_sort_order or 0),
"line_manual": bool(roster.line_manual),
"api_line": roster.api_line_number,
"api_line_sort_order": int(roster.api_line_sort_order or 0),
"captain_role": roster.captain_role, "captain_role": roster.captain_role,
"lineup_status": roster.lineup_status, "lineup_status": roster.lineup_status,
"birth_date": ( "birth_date": (
@@ -4937,12 +4949,29 @@ class HockeyDataService:
) )
for items in serialized_players.values(): for items in serialized_players.values():
items.sort( has_lines = any(str(item.get("line") or "").strip() for item in items)
key=lambda item: ( if has_lines:
self._number_sort_value(str(item["number"])), def line_sort_value(item: dict[str, Any]) -> tuple[Any, ...]:
str(item["name"]), raw_line = str(item.get("line") or "").strip()
try:
line_number = int(raw_line)
except (TypeError, ValueError):
line_number = 10_000 if not raw_line else 9_000
order = int(item.get("line_sort_order") or 0)
return (
line_number,
order if order > 0 else 10_000,
self._number_sort_value(str(item.get("number") or "")),
str(item.get("name") or ""),
)
items.sort(key=line_sort_value)
else:
items.sort(
key=lambda item: (
self._number_sort_value(str(item["number"])),
str(item["name"]),
)
) )
)
serialized_officials: list[dict[str, Any]] = [] serialized_officials: list[dict[str, Any]] = []
role_labels = { role_labels = {
@@ -6102,6 +6131,102 @@ class HockeyDataService:
raise ValueError("Матч не найден") raise ValueError("Матч не найден")
return result return result
def save_game_lines(
self,
game_external_id: str,
*,
side: str,
assignments: list[dict[str, Any]],
updated_by: str = "",
language: str = "ru",
) -> dict[str, Any]:
"""Persist the operator's effective line order for one team.
BUILD122 deliberately stores the result on ``hockey_game_rosters`` so
broadcast SQL can read the same roster table directly. ``line_manual``
protects these values from later Stat2TV roster refreshes while the
parallel api_* fields keep the newest API baseline available for reset.
"""
side = str(side or "").strip().lower()
if side not in {"home", "away"}:
raise ValueError("Неизвестная сторона команды")
normalised: dict[str, tuple[str, int]] = {}
for raw in assignments or []:
player_id = str(raw.get("player_external_id") or raw.get("player_id") or "").strip()
if not player_id:
continue
line = str(raw.get("line_number") or "").strip()
if line:
if not line.isdigit() or not (1 <= int(line) <= 99):
raise ValueError("Номер звена должен быть от 1 до 99")
line = str(int(line))
try:
sort_order = max(0, int(raw.get("sort_order") or 0))
except (TypeError, ValueError):
sort_order = 0
normalised[player_id] = (line, sort_order)
with self.database.session() as session:
game = session.scalar(select(Game).where(Game.external_id == str(game_external_id)))
if game is None:
raise ValueError("Матч не найден")
rosters = list(session.scalars(select(GameRoster).where(and_(
GameRoster.game_external_id == game.external_id,
GameRoster.side == side,
))))
roster_by_id = {str(row.player_external_id): row for row in rosters}
unknown = sorted(set(normalised) - set(roster_by_id))
if unknown:
raise ValueError(f"Игрок не найден в составе: {unknown[0]}")
for player_id, (line, sort_order) in normalised.items():
roster = roster_by_id[player_id]
# First manual edit after upgrading from an older build: keep the
# already stored API line as a reset baseline before overwriting it.
if not str(roster.api_line_number or "").strip() and str(roster.line_number or "").strip():
roster.api_line_number = str(roster.line_number or "").strip()
roster.api_line_sort_order = int(roster.line_sort_order or 0)
roster.line_number = line
roster.line_sort_order = sort_order
roster.line_manual = True
roster.synced_at = datetime.utcnow()
result = self.game_details(game_external_id, language=language)
if result is None:
raise ValueError("Матч не найден")
return result
def reset_game_lines(
self,
game_external_id: str,
*,
side: str,
updated_by: str = "",
language: str = "ru",
) -> dict[str, Any]:
"""Return one team's effective line layout to the latest API baseline."""
side = str(side or "").strip().lower()
if side not in {"home", "away"}:
raise ValueError("Неизвестная сторона команды")
with self.database.session() as session:
game = session.scalar(select(Game).where(Game.external_id == str(game_external_id)))
if game is None:
raise ValueError("Матч не найден")
rosters = list(session.scalars(select(GameRoster).where(and_(
GameRoster.game_external_id == game.external_id,
GameRoster.side == side,
))))
for roster in rosters:
roster.line_number = str(roster.api_line_number or "").strip()
roster.line_sort_order = int(roster.api_line_sort_order or 0)
roster.line_manual = False
roster.synced_at = datetime.utcnow()
result = self.game_details(game_external_id, language=language)
if result is None:
raise ValueError("Матч не найден")
return result
def set_game_period( def set_game_period(
self, self,
game_external_id: str, game_external_id: str,

View File

@@ -2597,7 +2597,9 @@ function startCustomTooltips() {
// BUILD86: "Заготовки" is always the final top-level runtime tab. // BUILD86: "Заготовки" is always the final top-level runtime tab.
// Reorder it on every config normalisation as older published configs may // Reorder it on every config normalisation as older published configs may
// already contain the tab near "Игра" from BUILD84/85. // already contain the tab near "Игра" from BUILD84/85.
state.config.tabs = state.config.tabs.filter((tab) => tab?.id !== "prepared_titles"); state.config.tabs = state.config.tabs.filter((tab) => !["lines", "prepared_titles"].includes(tab?.id));
// BUILD122: line/five sorting is a first-class operator workspace.
state.config.tabs.push({ id: "lines", label: "Пятёрки" });
state.config.tabs.push({ id: "prepared_titles", label: "Заготовки" }); state.config.tabs.push({ id: "prepared_titles", label: "Заготовки" });
if (state.activeTab === "prematch") state.activeTab = state.config.tabs.find((tab) => tab.id === "main")?.id || state.config.tabs[0].id; if (state.activeTab === "prematch") state.activeTab = state.config.tabs.find((tab) => tab.id === "main")?.id || state.config.tabs[0].id;
} }
@@ -6817,6 +6819,10 @@ function openTimerQuickEditor(focusActionId = "") {
name: get("nameField", `Игрок ${index + 1}`), name: get("nameField", `Игрок ${index + 1}`),
position: get("positionField", ""), position: get("positionField", ""),
role: String(row?.role || ""), role: String(row?.role || ""),
line: String(row?.line || row?.line_number || "").trim(),
lineSortOrder: Number(row?.line_sort_order || 0),
lineManual: Boolean(row?.line_manual),
apiLine: String(row?.api_line || row?.api_line_number || "").trim(),
raw: clone(row) raw: clone(row)
}; };
} }
@@ -6825,19 +6831,33 @@ function openTimerQuickEditor(focusActionId = "") {
const props = component.props || {}; const props = component.props || {};
const rows = getByPath(state.data, side === "home" ? props.homePlayersPath : props.awayPlayersPath); const rows = getByPath(state.data, side === "home" ? props.homePlayersPath : props.awayPlayersPath);
const limit = Math.max(1, Number(props.rosterLimit) || 40); const limit = Math.max(1, Number(props.rosterLimit) || 40);
return (Array.isArray(rows) ? rows : []) const players = (Array.isArray(rows) ? rows : [])
.map((row, index) => normalizeHockeyPlayer(component, row, side, index)) .map((row, index) => normalizeHockeyPlayer(component, row, side, index));
.sort((left, right) => { const hasLineData = players.some((player) => String(player.line || "").trim());
const leftNumber = String(left.number || "").replace(/^#/, ""); players.sort((left, right) => {
const rightNumber = String(right.number || "").replace(/^#/, ""); if (hasLineData) {
if (!leftNumber && rightNumber) return 1; const lineValue = (player) => {
if (leftNumber && !rightNumber) return -1; const raw = String(player.line || "").trim();
return leftNumber.localeCompare(rightNumber, undefined, { if (!raw) return 10000;
numeric: true, const number = Number(raw);
sensitivity: "base", return Number.isFinite(number) ? number : 9000;
}) || left.name.localeCompare(right.name); };
}) const lineDiff = lineValue(left) - lineValue(right);
.slice(0, limit); if (lineDiff) return lineDiff;
const leftOrder = Number(left.lineSortOrder || 0) || 10000;
const rightOrder = Number(right.lineSortOrder || 0) || 10000;
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
}
const leftNumber = String(left.number || "").replace(/^#/, "");
const rightNumber = String(right.number || "").replace(/^#/, "");
if (!leftNumber && rightNumber) return 1;
if (leftNumber && !rightNumber) return -1;
return leftNumber.localeCompare(rightNumber, undefined, {
numeric: true,
sensitivity: "base",
}) || left.name.localeCompare(right.name);
});
return players.slice(0, limit);
} }
function hockeyRosterCounts(players) { function hockeyRosterCounts(players) {
@@ -6860,6 +6880,296 @@ function openTimerQuickEditor(focusActionId = "") {
return `<em class="hpd-roster-count-breakdown" data-tooltip="${escapeHtml(tooltip)}" aria-label="${escapeHtml(tooltip)}"><b>${counts.total}</b><span>${counts.forwards}</span><span>${counts.defenders}</span><span>${counts.goalkeepers}</span></em>`; return `<em class="hpd-roster-count-breakdown" data-tooltip="${escapeHtml(tooltip)}" aria-label="${escapeHtml(tooltip)}"><b>${counts.total}</b><span>${counts.forwards}</span><span>${counts.defenders}</span><span>${counts.goalkeepers}</span></em>`;
} }
// BUILD122 — operator line/five sorting.
function hockeyLinesGame() {
return getByPath(state.data, "hockey.selected_game") || null;
}
function hockeyLinesPlayers(side) {
const game = hockeyLinesGame();
const rows = game?.[side]?.players;
return Array.isArray(rows) ? rows : [];
}
function hockeyLineNumericValue(value) {
const raw = String(value || "").trim();
if (!raw) return 10000;
const number = Number(raw);
return Number.isFinite(number) ? number : 9000;
}
function hockeyLinePlayerNumberValue(player) {
const raw = String(player?.number || "").replace(/^#/, "").trim();
const number = Number(raw);
return Number.isFinite(number) ? number : 10000;
}
function hockeyLinePlayersSorted(side, lineNumber) {
const line = String(lineNumber || "").trim();
return hockeyLinesPlayers(side)
.filter((player) => String(player?.line || "").trim() === line)
.slice()
.sort((left, right) => {
const leftOrder = Number(left?.line_sort_order || 0) || 10000;
const rightOrder = Number(right?.line_sort_order || 0) || 10000;
return leftOrder - rightOrder
|| hockeyLinePlayerNumberValue(left) - hockeyLinePlayerNumberValue(right)
|| String(left?.name || "").localeCompare(String(right?.name || ""), undefined, { sensitivity: "base" });
});
}
function hockeyLineVisibleCount(side) {
const key = `hockeyLinesVisible:${side}`;
const maxAssigned = hockeyLinesPlayers(side).reduce((maximum, player) => {
const value = Number(String(player?.line || "").trim());
return Number.isFinite(value) ? Math.max(maximum, value) : maximum;
}, 0);
const current = Number(state.formValues[key] || 0);
const next = Math.max(4, maxAssigned, current);
state.formValues[key] = next;
return next;
}
function hockeyReindexLine(side, lineNumber) {
hockeyLinePlayersSorted(side, lineNumber).forEach((player, index) => {
player.line_sort_order = index + 1;
});
}
function hockeyMoveLinePlayer(side, playerId, targetLine, beforePlayerId = "") {
const players = hockeyLinesPlayers(side);
const player = players.find((item) => String(item?.external_id || item?.id || "") === String(playerId || ""));
if (!player) return false;
const sourceLine = String(player.line || "").trim();
const nextLine = String(targetLine || "").trim();
const targetPlayers = hockeyLinePlayersSorted(side, nextLine).filter((item) => item !== player);
let insertIndex = targetPlayers.length;
if (beforePlayerId) {
const found = targetPlayers.findIndex((item) => String(item?.external_id || item?.id || "") === String(beforePlayerId));
if (found >= 0) insertIndex = found;
}
player.line = nextLine;
targetPlayers.splice(insertIndex, 0, player);
targetPlayers.forEach((item, index) => { item.line_sort_order = index + 1; });
if (sourceLine !== nextLine) hockeyReindexLine(side, sourceLine);
return true;
}
async function hockeySaveLineAssignments(side) {
const game = hockeyLinesGame();
const gameId = String(game?.external_id || game?.id || "").trim();
if (!gameId) throw new Error("Сначала выберите матч");
const assignments = hockeyLinesPlayers(side).map((player) => ({
player_external_id: String(player?.external_id || player?.id || ""),
line_number: String(player?.line || "").trim(),
sort_order: Number(player?.line_sort_order || 0),
})).filter((item) => item.player_external_id);
const payload = await hockeyGameControlRequest(`/games/${encodeURIComponent(gameId)}/lines`, {
method: "PUT",
body: JSON.stringify({
side,
assignments,
language: hockeyGameControlLanguage(),
}),
});
state.data.hockey ||= {};
const current = state.data.hockey.selected_game || {};
current[side] ||= {};
current[side].players = Array.isArray(payload?.[side]?.players) ? payload[side].players : current[side].players;
if (payload?.player_statistics) current.player_statistics = payload.player_statistics;
if (payload?.season_player_statistics) current.season_player_statistics = payload.season_player_statistics;
state.data.hockey.selected_game = current;
return payload;
}
async function hockeyResetLineAssignments(side) {
const game = hockeyLinesGame();
const gameId = String(game?.external_id || game?.id || "").trim();
if (!gameId) throw new Error("Сначала выберите матч");
const payload = await hockeyGameControlRequest(`/games/${encodeURIComponent(gameId)}/lines`, {
method: "PUT",
body: JSON.stringify({
side,
assignments: [],
reset: true,
language: hockeyGameControlLanguage(),
}),
});
state.data.hockey ||= {};
const current = state.data.hockey.selected_game || {};
current[side] ||= {};
current[side].players = Array.isArray(payload?.[side]?.players) ? payload[side].players : current[side].players;
if (payload?.player_statistics) current.player_statistics = payload.player_statistics;
if (payload?.season_player_statistics) current.season_player_statistics = payload.season_player_statistics;
state.data.hockey.selected_game = current;
state.formValues[`hockeyLinesVisible:${side}`] = 0;
return payload;
}
function hockeyLinePlayerCard(side, player) {
const node = div("hockey-line-player");
const playerId = String(player?.external_id || player?.id || "");
node.draggable = true;
node.dataset.playerId = playerId;
node.dataset.side = side;
node.innerHTML = `
<span class="hockey-line-drag">⋮⋮</span>
<b>${escapeHtml(player?.number ? `#${player.number}` : "—")}</b>
<strong>${escapeHtml(player?.name || "Игрок")}</strong>
<small>${escapeHtml(player?.position || "")}</small>
`;
node.addEventListener("dragstart", (event) => {
const payload = JSON.stringify({ side, playerId });
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("application/x-hockey-line-player", payload);
event.dataTransfer.setData("text/plain", payload);
node.classList.add("is-dragging");
});
node.addEventListener("dragend", () => node.classList.remove("is-dragging"));
return node;
}
function hockeyReadLineDrag(event) {
try {
const raw = event.dataTransfer?.getData("application/x-hockey-line-player") || event.dataTransfer?.getData("text/plain") || "";
const payload = JSON.parse(raw);
if (!payload?.side || !payload?.playerId) return null;
return payload;
} catch (_) {
return null;
}
}
function hockeyLineDropZone(side, lineNumber, players) {
const zone = div("hockey-line-dropzone");
zone.dataset.side = side;
zone.dataset.line = String(lineNumber || "");
if (!players.length) zone.innerHTML = `<span>Перетащите игрока сюда</span>`;
players.forEach((player) => {
const card = hockeyLinePlayerCard(side, player);
card.addEventListener("dragover", (event) => {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = "move";
card.classList.add("is-drop-before");
});
card.addEventListener("dragleave", () => card.classList.remove("is-drop-before"));
card.addEventListener("drop", async (event) => {
event.preventDefault();
event.stopPropagation();
card.classList.remove("is-drop-before");
const dragged = hockeyReadLineDrag(event);
if (!dragged || dragged.side !== side) return;
if (!hockeyMoveLinePlayer(side, dragged.playerId, lineNumber, String(player?.external_id || player?.id || ""))) return;
renderRuntime();
try {
await hockeySaveLineAssignments(side);
renderRuntime();
} catch (error) {
toast(`Не удалось сохранить пятёрки: ${error.message}`, true);
}
});
zone.appendChild(card);
});
zone.addEventListener("dragover", (event) => {
const dragged = hockeyReadLineDrag(event);
if (!dragged || dragged.side !== side) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
zone.classList.add("is-over");
});
zone.addEventListener("dragleave", (event) => {
if (!zone.contains(event.relatedTarget)) zone.classList.remove("is-over");
});
zone.addEventListener("drop", async (event) => {
if (event.target.closest(".hockey-line-player")) return;
event.preventDefault();
zone.classList.remove("is-over");
const dragged = hockeyReadLineDrag(event);
if (!dragged || dragged.side !== side) return;
if (!hockeyMoveLinePlayer(side, dragged.playerId, lineNumber)) return;
renderRuntime();
try {
await hockeySaveLineAssignments(side);
renderRuntime();
} catch (error) {
toast(`Не удалось сохранить пятёрки: ${error.message}`, true);
}
});
return zone;
}
function hockeyLineTeamColumn(side) {
const game = hockeyLinesGame() || {};
const team = game?.[side] || {};
const column = div(`hockey-lines-team side-${side}`);
const players = hockeyLinesPlayers(side);
const manualCount = players.filter((player) => Boolean(player?.line_manual)).length;
const header = div("hockey-lines-team-header");
header.innerHTML = `
<div><span>${side === "home" ? "ЛЕВАЯ КОМАНДА" : "ПРАВАЯ КОМАНДА"}</span><strong>${escapeHtml(team?.name || (side === "home" ? "Хозяева" : "Гости"))}</strong></div>
<em>${manualCount ? `ручн. ${manualCount}` : "API"}</em>
`;
const actions = div("hockey-lines-actions");
const addButton = document.createElement("button");
addButton.type = "button";
addButton.textContent = "+ Звено";
addButton.addEventListener("click", () => {
state.formValues[`hockeyLinesVisible:${side}`] = hockeyLineVisibleCount(side) + 1;
renderRuntime();
});
const resetButton = document.createElement("button");
resetButton.type = "button";
resetButton.textContent = "Вернуть из API";
resetButton.title = "Снять ручные назначения и вернуть последнее звено/порядок, полученные из API";
resetButton.addEventListener("click", async () => {
resetButton.disabled = true;
try {
await hockeyResetLineAssignments(side);
toast(`Пятёрки ${side === "home" ? "HOME" : "AWAY"}: восстановлены из API`);
renderRuntime();
} catch (error) {
resetButton.disabled = false;
toast(`Не удалось вернуть пятёрки: ${error.message}`, true);
}
});
actions.append(addButton, resetButton);
column.append(header, actions);
const unassigned = div("hockey-line-card is-unassigned");
const unassignedPlayers = hockeyLinePlayersSorted(side, "");
unassigned.innerHTML = `<header><strong>Без звена</strong><span>${unassignedPlayers.length}</span></header>`;
unassigned.appendChild(hockeyLineDropZone(side, "", unassignedPlayers));
column.appendChild(unassigned);
const count = hockeyLineVisibleCount(side);
for (let line = 1; line <= count; line += 1) {
const linePlayers = hockeyLinePlayersSorted(side, String(line));
const card = div("hockey-line-card");
card.innerHTML = `<header><strong>Звено ${line}</strong><span>${linePlayers.length}</span></header>`;
card.appendChild(hockeyLineDropZone(side, String(line), linePlayers));
column.appendChild(card);
}
return column;
}
function renderHockeyLinesWorkspace() {
const workspace = div("hockey-lines-workspace");
const game = hockeyLinesGame();
if (!game) {
workspace.innerHTML = `<div class="hockey-lines-empty"><strong>Выберите матч</strong><span>После загрузки матча здесь появятся составы для сортировки по звеньям.</span></div>`;
return workspace;
}
const intro = div("hockey-lines-intro");
intro.innerHTML = `<div><strong>Пятёрки / звенья</strong><span>Перетаскивайте игроков между звеньями и меняйте порядок внутри звена. Изменения сохраняются в базе автоматически.</span></div><small>SQL: hockey_game_rosters.line_number + line_sort_order</small>`;
workspace.append(intro);
const teams = div("hockey-lines-columns");
teams.append(hockeyLineTeamColumn("home"), hockeyLineTeamColumn("away"));
workspace.appendChild(teams);
return workspace;
}
function hockeyTeamName(component, side) { function hockeyTeamName(component, side) {
const path = side === "home" ? component.props?.homeTeamPath : component.props?.awayTeamPath; const path = side === "home" ? component.props?.homeTeamPath : component.props?.awayTeamPath;
return formatValue(getByPath(state.data, path), side === "home" ? "Хозяева" : "Гости"); return formatValue(getByPath(state.data, path), side === "home" ? "Хозяева" : "Гости");
@@ -13272,7 +13582,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
} }
function runtimeHasFixedHockeyCanvas() { function runtimeHasFixedHockeyCanvas() {
if (state.activeTab === "prepared_titles") return true; if (["prepared_titles", "lines"].includes(state.activeTab)) return true;
return state.config.components.some((component) => return state.config.components.some((component) =>
[ [
"hockey_penalty_dashboard", "hockey_penalty_dashboard",
@@ -13382,6 +13692,8 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
if (state.preparedTitlesLoadedKey !== hockeyPreparedWorkspaceKey() && !state.preparedTitlesLoading) { if (state.preparedTitlesLoadedKey !== hockeyPreparedWorkspaceKey() && !state.preparedTitlesLoading) {
setTimeout(() => hockeyLoadPreparedTitlesWorkspace({ force: true }).catch(() => {}), 0); setTimeout(() => hockeyLoadPreparedTitlesWorkspace({ force: true }).catch(() => {}), 0);
} }
} else if (state.activeTab === "lines") {
el.runtimeStage.appendChild(renderHockeyLinesWorkspace());
} else { } else {
state.config.components.filter((component) => !component.hidden && component.props?.runtimeHidden !== true && state.runtimeVisibility[component.action_id] !== false && isEffectivelyOnActiveTab(component)).sort((a, b) => Number(a.z) - Number(b.z)).forEach((component) => { state.config.components.filter((component) => !component.hidden && component.props?.runtimeHidden !== true && state.runtimeVisibility[component.action_id] !== false && isEffectivelyOnActiveTab(component)).sort((a, b) => Number(a.z) - Number(b.z)).forEach((component) => {
const wrapper = document.createElement("div"); wrapper.className = "runtime-component"; Object.assign(wrapper.style, { left: `${component.x}px`, top: `${component.y}px`, width: `${component.w}px`, height: `${component.h}px`, zIndex: String(component.z) }); wrapper.appendChild(renderComponent(component, true)); el.runtimeStage.appendChild(wrapper); const wrapper = document.createElement("div"); wrapper.className = "runtime-component"; Object.assign(wrapper.style, { left: `${component.x}px`, top: `${component.y}px`, width: `${component.w}px`, height: `${component.h}px`, zIndex: String(component.z) }); wrapper.appendChild(renderComponent(component, true)); el.runtimeStage.appendChild(wrapper);

View File

@@ -7778,3 +7778,26 @@ body.hockey-navigation-open .runtime-viewport.has-hockey-pbp { gap: 14px !import
.quick-score-team{display:none} .quick-score-team{display:none}
.quick-score-control{padding-inline:4px} .quick-score-control{padding-inline:4px}
} }
/* BUILD122 — drag-and-drop hockey line/five sorter. */
.hockey-lines-workspace{
position:absolute;inset:0;display:grid;grid-template-rows:auto minmax(0,1fr);gap:10px;
padding:14px;box-sizing:border-box;color:#dcecff;background:linear-gradient(180deg,#0a1522 0%,#09131f 100%);overflow:hidden;
}
.hockey-lines-intro{
min-height:54px;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 13px;
border:1px solid #263f56;border-radius:11px;background:#0c1b2a;
}
.hockey-lines-intro>div{min-width:0;display:grid;gap:3px}.hockey-lines-intro strong{font-size:15px}.hockey-lines-intro span{color:#7f96ad;font-size:8px}.hockey-lines-intro small{color:#56e3c1;font-size:8px;font-weight:850;white-space:nowrap}
.hockey-lines-columns{min-height:0;display:grid;grid-template-columns:1fr 1fr;gap:12px;overflow:hidden}
.hockey-lines-team{min-width:0;min-height:0;display:flex;flex-direction:column;gap:8px;padding:10px;border:1px solid #243a50;border-radius:12px;background:#0b1725;overflow-y:auto}
.hockey-lines-team.side-home{box-shadow:inset 3px 0 0 #4d9cff}.hockey-lines-team.side-away{box-shadow:inset -3px 0 0 #ff5f79}
.hockey-lines-team-header{display:flex;align-items:center;justify-content:space-between;gap:10px;position:sticky;top:-10px;z-index:3;padding:7px 4px 8px;background:#0b1725}
.hockey-lines-team-header>div{min-width:0;display:grid;gap:2px}.hockey-lines-team-header span{color:#70879f;font-size:7px;font-weight:950;letter-spacing:.09em}.hockey-lines-team-header strong{overflow:hidden;font-size:13px;text-overflow:ellipsis;white-space:nowrap}.hockey-lines-team-header em{padding:4px 8px;border:1px solid #2b475f;border-radius:999px;color:#80a0ba;background:#102235;font-size:7px;font-style:normal;font-weight:900}
.hockey-lines-actions{display:flex;gap:6px}.hockey-lines-actions button{min-height:28px;padding:0 10px;border:1px solid #31516a;border-radius:7px;color:#cfe3f3;background:#10263a;font-size:8px;font-weight:900;cursor:pointer}.hockey-lines-actions button:hover{border-color:#54dcbf;color:#fff}.hockey-lines-actions button:disabled{opacity:.45;cursor:default}
.hockey-line-card{display:grid;gap:6px;padding:7px;border:1px solid #22394f;border-radius:10px;background:#0e1d2c}.hockey-line-card.is-unassigned{border-style:dashed;background:#0a1622}
.hockey-line-card>header{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:0 2px}.hockey-line-card>header strong{font-size:9px}.hockey-line-card>header span{display:grid;place-items:center;min-width:22px;height:18px;padding:0 5px;border-radius:999px;color:#7e97af;background:#14283b;font-size:7px;font-weight:950}
.hockey-line-dropzone{min-height:38px;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:5px;padding:4px;border:1px solid transparent;border-radius:8px;transition:border-color .12s,background .12s}
.hockey-line-dropzone>span{grid-column:1/-1;align-self:center;color:#4f6880;font-size:8px;text-align:center}.hockey-line-dropzone.is-over{border-color:#54dcbf;background:rgba(84,220,191,.06)}
.hockey-line-player{min-width:0;display:grid;grid-template-columns:12px 31px minmax(0,1fr);grid-template-rows:auto auto;column-gap:5px;align-items:center;padding:5px 6px;border:1px solid #29445c;border-radius:8px;background:#102235;cursor:grab;transition:transform .1s,border-color .1s,opacity .1s}.hockey-line-player:hover{border-color:#47708f;transform:translateY(-1px)}.hockey-line-player.is-dragging{opacity:.35}.hockey-line-player.is-drop-before{border-color:#54e5c4;box-shadow:inset 3px 0 0 #54e5c4}.hockey-line-drag{grid-row:1/3;color:#58738c;font-size:9px;letter-spacing:-2px}.hockey-line-player b{grid-row:1/3;display:grid;place-items:center;min-height:27px;border-radius:6px;color:#dcecff;background:#0a1724;font-size:8px}.hockey-line-player strong{min-width:0;overflow:hidden;font-size:9px;text-overflow:ellipsis;white-space:nowrap}.hockey-line-player small{min-width:0;overflow:hidden;color:#6f879e;font-size:7px;text-overflow:ellipsis;white-space:nowrap}
.hockey-lines-empty{position:absolute;inset:0;display:grid;place-content:center;gap:6px;color:#7890a8;text-align:center}.hockey-lines-empty strong{color:#d9e8f6;font-size:17px}.hockey-lines-empty span{font-size:9px}