build 122 - стартовые пятерки
This commit is contained in:
2
app.py
2
app.py
@@ -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"
|
||||||
|
|||||||
@@ -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="")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,6 +4949,23 @@ class HockeyDataService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
for items in serialized_players.values():
|
for items in serialized_players.values():
|
||||||
|
has_lines = any(str(item.get("line") or "").strip() for item in items)
|
||||||
|
if has_lines:
|
||||||
|
def line_sort_value(item: dict[str, Any]) -> tuple[Any, ...]:
|
||||||
|
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(
|
items.sort(
|
||||||
key=lambda item: (
|
key=lambda item: (
|
||||||
self._number_sort_value(str(item["number"])),
|
self._number_sort_value(str(item["number"])),
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,9 +6831,23 @@ 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());
|
||||||
|
players.sort((left, right) => {
|
||||||
|
if (hasLineData) {
|
||||||
|
const lineValue = (player) => {
|
||||||
|
const raw = String(player.line || "").trim();
|
||||||
|
if (!raw) return 10000;
|
||||||
|
const number = Number(raw);
|
||||||
|
return Number.isFinite(number) ? number : 9000;
|
||||||
|
};
|
||||||
|
const lineDiff = lineValue(left) - lineValue(right);
|
||||||
|
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 leftNumber = String(left.number || "").replace(/^#/, "");
|
||||||
const rightNumber = String(right.number || "").replace(/^#/, "");
|
const rightNumber = String(right.number || "").replace(/^#/, "");
|
||||||
if (!leftNumber && rightNumber) return 1;
|
if (!leftNumber && rightNumber) return 1;
|
||||||
@@ -6836,8 +6856,8 @@ function openTimerQuickEditor(focusActionId = "") {
|
|||||||
numeric: true,
|
numeric: true,
|
||||||
sensitivity: "base",
|
sensitivity: "base",
|
||||||
}) || left.name.localeCompare(right.name);
|
}) || left.name.localeCompare(right.name);
|
||||||
})
|
});
|
||||||
.slice(0, limit);
|
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);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user