экспорт маппинга

This commit is contained in:
2026-08-19 17:32:13 +03:00
parent c643a183d8
commit 614a61168a
17 changed files with 1012 additions and 35 deletions

View File

@@ -5951,8 +5951,14 @@ function openTimerQuickEditor(focusActionId = "") {
function normalizeHockeyPlayer(component, row, side, index) {
const props = component.props || {};
const get = (fieldName, fallback = "") => formatValue(getByPath(row, props[fieldName]), fallback);
const externalId = String(get("idField", "") || row?.external_id || row?.id || "");
const databaseId = String(row?.db_id ?? row?.database_id ?? "");
return {
id: String(get("idField", `${side}-${index + 1}`)),
// `id` остаётся external_id для совместимости со старым Mapping.
// `dbId` — реальный PK hockey_players.id из нашей базы.
id: externalId || `${side}-${index + 1}`,
externalId,
dbId: databaseId,
side,
number: get("numberField", ""),
name: get("nameField", `Игрок ${index + 1}`),
@@ -6028,6 +6034,9 @@ function openTimerQuickEditor(focusActionId = "") {
selectedInfraction: null,
selectedPreset: null,
selectedEventId: null,
// Последний выбранный для редактирования штраф остаётся общим, а
// preview/Mapping-выбор хранится отдельно для левой и правой команды.
selectedPreviewEventIds: { home: "", away: "" },
penalties: [],
history: [],
historyOpen: false,
@@ -6128,11 +6137,15 @@ function openTimerQuickEditor(focusActionId = "") {
}
function hockeyPenaltyContext(event) {
const playerExternalId = String(event.player?.externalId || event.player?.external_id || event.player?.id || "");
const playerDbId = String(event.player?.dbId || event.player?.db_id || event.player?.database_id || event.player?.raw?.db_id || "");
return {
penalty_id: event.id,
event_time: event.eventTime,
event_time_ms: event.eventTimeMs,
player: event.player ? clone(event.player) : null,
player_id: playerExternalId,
player_db_id: playerDbId,
team_penalty: Boolean(event.teamPenalty),
infraction: event.infraction ? clone(event.infraction) : null,
preset: event.preset,
@@ -6458,6 +6471,7 @@ function openTimerQuickEditor(focusActionId = "") {
const side = String(event.player?.side || event.side || "").toLowerCase();
const remainingOnSide = board.penalties.filter((item) => !item.finished && hockeyEventReady(item) && String(item.player?.side || item.side || "").toLowerCase() === side).length;
if (board.selectedEventId === event.id) board.selectedEventId = null;
if (board.selectedPreviewEventIds?.[side] === event.id) board.selectedPreviewEventIds[side] = "";
persistHockeyBoard(component, board, true);
refreshHockeyBoardNodes(component);
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true })
@@ -6476,6 +6490,10 @@ function openTimerQuickEditor(focusActionId = "") {
} else if (command === "remove") {
board.penalties = board.penalties.filter((item) => item.id !== eventId);
if (board.selectedEventId === eventId) board.selectedEventId = null;
{
const removedSide = String(event.player?.side || event.side || "").toLowerCase();
if (board.selectedPreviewEventIds?.[removedSide] === eventId) board.selectedPreviewEventIds[removedSide] = "";
}
addHockeyHistory(
component,
board,
@@ -7043,21 +7061,71 @@ function hockeyInfractionDropdown(component, runtime) {
return field;
}
function hockeyPenaltyPlayerIdentifiers(component, event) {
const side = String(event.player?.side || event.side || "").toLowerCase();
let externalId = String(
event.player?.externalId
|| event.player?.external_id
|| event.player?.id
|| event.player?.raw?.external_id
|| event.player?.raw?.id
|| ""
);
let dbId = String(
event.player?.dbId
|| event.player?.db_id
|| event.player?.database_id
|| event.player?.raw?.db_id
|| event.player?.raw?.database_id
|| ""
);
// Для сохранённых удалений из старых сборок dbId мог отсутствовать.
// В этом случае восстанавливаем его по актуальному составу из БД.
if (externalId && !dbId && ["home", "away"].includes(side)) {
const rosterPlayer = hockeyRoster(component, side).find((player) =>
String(player.externalId || player.id || "") === externalId
);
if (rosterPlayer) {
dbId = String(rosterPlayer.dbId || rosterPlayer.raw?.db_id || "");
externalId = String(rosterPlayer.externalId || rosterPlayer.id || externalId);
}
}
return { externalId, dbId };
}
function hockeyPenaltyIsPreviewSelected(board, event) {
const side = String(event.player?.side || event.side || "").toLowerCase();
return ["home", "away"].includes(side)
&& String(board.selectedPreviewEventIds?.[side] || "") === String(event.id || "");
}
async function hockeySelectPenaltyForPreview(component, event) {
const board = ensureHockeyBoardState(component);
board.selectedEventId = event.id;
if (!board.selectedPreviewEventIds || typeof board.selectedPreviewEventIds !== "object") {
board.selectedPreviewEventIds = { home: "", away: "" };
}
const side = String(event.player?.side || event.side || "").toLowerCase();
if (["home", "away"].includes(side)) board.selectedPreviewEventIds[side] = event.id;
const teamId = side === "home"
? String(getByPath(state.data, "hockey.selected_game.home.id") || getByPath(state.data, "hockey.selected_game.home.external_id") || "")
: side === "away"
? String(getByPath(state.data, "hockey.selected_game.away.id") || getByPath(state.data, "hockey.selected_game.away.external_id") || "")
: "";
const playerIds = hockeyPenaltyPlayerIdentifiers(component, event);
const detail = {
item_id: String(event.external_id || event.id || ""),
value: String(event.external_id || event.id || ""),
penalty_id: String(event.external_id || event.id || ""),
player_id: String(event.player?.id || ""),
// Старый player_id остаётся external_id, чтобы не ломать существующие SQL.
player_id: playerIds.externalId,
player_external_id: playerIds.externalId,
player_db_id: playerIds.dbId,
team_id: teamId,
team_penalty: Boolean(event.teamPenalty),
infraction_id: String(event.infraction?.id || ""),
preset_id: String(event.preset || ""),
side,
@@ -7067,7 +7135,14 @@ async function hockeySelectPenaltyForPreview(component, event) {
status: event.finished ? "finished" : event.running ? "running" : hockeyEventReady(event) ? "ready" : "draft",
selected: true,
};
window.UIBuilderRuntime?.patchData?.({ hockey: { selected_penalty: detail } }, { render: false });
const sidePatch = ["home", "away"].includes(side) ? { [side]: detail } : {};
window.UIBuilderRuntime?.patchData?.({
hockey: {
selected_penalty: detail,
selected_penalties: sidePatch,
}
}, { render: false });
refreshHockeyBoardNodes(component);
const gameId = hockeyTimerSelectedGameId();
@@ -7075,24 +7150,45 @@ async function hockeySelectPenaltyForPreview(component, event) {
emitInteraction(component, "penalty_selected", detail);
return detail;
}
const values = {
selected_penalty_id: detail.penalty_id,
selected_penalty_player_id: detail.player_id,
selected_penalty_player_db_id: detail.player_db_id,
selected_penalty_team_penalty: detail.team_penalty ? "1" : "0",
selected_penalty_team_id: detail.team_id,
selected_penalty_infraction_id: detail.infraction_id,
selected_penalty_preset_id: detail.preset_id,
selected_penalty_side: detail.side,
selected_penalty_event_time: detail.event_time,
selected_penalty_status: detail.status,
selected_penalty_remaining_ms: String(detail.remaining_ms),
selected_penalty_duration_ms: String(detail.duration_ms),
selected_event_id: detail.penalty_id,
};
if (side === "home") {
Object.assign(values, {
selected_home_penalty_id: detail.penalty_id,
selected_home_penalty_player_id: detail.player_id,
selected_home_penalty_player_db_id: detail.player_db_id,
selected_home_penalty_team_penalty: detail.team_penalty ? "1" : "0",
});
} else if (side === "away") {
Object.assign(values, {
selected_away_penalty_id: detail.penalty_id,
selected_away_penalty_player_id: detail.player_id,
selected_away_penalty_player_db_id: detail.player_db_id,
selected_away_penalty_team_penalty: detail.team_penalty ? "1" : "0",
});
}
try {
const response = await fetch("/api/hockey/context/batch", {
method: "POST", cache: "no-store", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
values: {
selected_penalty_id: detail.penalty_id,
selected_penalty_player_id: detail.player_id,
selected_penalty_team_id: detail.team_id,
selected_penalty_infraction_id: detail.infraction_id,
selected_penalty_preset_id: detail.preset_id,
selected_penalty_side: detail.side,
selected_penalty_event_time: detail.event_time,
selected_penalty_status: detail.status,
selected_penalty_remaining_ms: String(detail.remaining_ms),
selected_penalty_duration_ms: String(detail.duration_ms),
selected_event_id: detail.penalty_id,
},
values,
context: {
game_id: gameId,
device_id: currentRuntimeVmixDeviceId(),
@@ -7119,6 +7215,7 @@ function hockeyPenaltyCard(component, event, runtime) {
const card = div(`hpd-team-penalty-card team-${side}`);
card.dataset.penaltyId = event.id;
card.classList.toggle("is-selected", board.selectedEventId === event.id);
card.classList.toggle("is-preview-selected", hockeyPenaltyIsPreviewSelected(board, event));
card.classList.toggle("is-running", event.running);
card.classList.toggle("is-finished", event.finished);
card.classList.toggle("is-ready", hockeyEventReady(event) && !event.running && !event.finished);
@@ -7134,6 +7231,8 @@ function hockeyPenaltyCard(component, event, runtime) {
const presetText = event.preset
? `${escapeHtml(event.preset)} · ${escapeHtml(formatHockeyPenaltyTime(event.durationMs))}`
: "Выберите длительность";
const previewSelected = hockeyPenaltyIsPreviewSelected(board, event);
const playerIds = hockeyPenaltyPlayerIdentifiers(component, event);
card.innerHTML = `
<div class="hpd-team-card-accent"></div>
@@ -7165,9 +7264,18 @@ function hockeyPenaltyCard(component, event, runtime) {
<span>Командное удаление — без игрока</span>
</label>
${previewSelected ? `
<div class="hpd-team-card-identifiers" title="Идентификаторы выбранного удаления для Mapping">
<span><b>ID удаления</b>${escapeHtml(String(event.external_id || event.id || "—"))}</span>
<span><b>Player external ID</b>${escapeHtml(playerIds.externalId || "—")}</span>
<span><b>Player DB ID</b>${escapeHtml(playerIds.dbId || "—")}</span>
<span><b>Командное</b>${event.teamPenalty ? "1" : "0"}</span>
</div>
` : ""}
<div class="hpd-team-card-footer">
<span class="hpd-team-card-status">${
board.selectedEventId === event.id
previewSelected
? "Выбрано · подсмотр"
: event.finished
? "Завершено"
@@ -11116,7 +11224,25 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
el.runtimeSizer.style.height = `${Math.round(canvasHeight * scale * 1000) / 1000}px`;
el.runtimeStage.style.width = `${canvasWidth}px`;
el.runtimeStage.style.height = `${canvasHeight}px`;
el.runtimeStage.style.transform = `translate3d(0, 0, 0) scale(${scale})`;
// BUILD 70: scale the operator UI with CSS zoom when available. Unlike a
// fractional transform: scale(), CSS zoom asks Chromium to lay out and
// rasterize text/1px borders at the requested size instead of enlarging a
// pre-rendered texture. This keeps every runtime tab visibly sharper.
const canUseCssZoom = typeof CSS !== "undefined"
&& typeof CSS.supports === "function"
&& CSS.supports("zoom", "1");
if (canUseCssZoom) {
el.runtimeStage.style.zoom = String(scale);
el.runtimeStage.style.transform = "none";
el.runtimeStage.style.willChange = "auto";
} else {
// Old embedded browsers still get the previous responsive behaviour,
// but avoid translate3d so we do not force an extra GPU texture layer.
el.runtimeStage.style.zoom = "";
el.runtimeStage.style.transform = `scale(${scale})`;
el.runtimeStage.style.willChange = "transform";
}
el.runtimeStage.style.background = state.config.canvas.background || "#0c1421";
el.runtimeStage.dataset.runtimeScale = String(scale);
}
@@ -12718,6 +12844,9 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
state.modifierShortcutChordUsedKey = true;
}
if (editorHotkeyPressed(event)) {
// In runtime the editor entry button is revealed only for admins by
// the hockey account bootstrap. Hidden means editor access is denied.
if (boot.mode !== "editor" && el.runtimeEditorBtn?.classList.contains("hidden")) return;
event.preventDefault();
event.stopPropagation();
if (boot.mode === "editor") window.location.href = boot.runtimeUrl;

View File

@@ -156,12 +156,12 @@
<header class="runtime-topbar">
<div>
<div class="eyebrow">RUNTIME INTERFACE</div>
<div class="runtime-title-line"><strong id="runtimeTitle">Интерфейс</strong><small class="runtime-build-badge">BUILD 2026.08.19.24</small></div>
<div class="runtime-title-line"><strong id="runtimeTitle">Интерфейс</strong><small class="runtime-build-badge">BUILD 2026.08.19.28</small></div>
</div>
<div id="runtimeTabs" class="runtime-tabs"></div>
<div class="runtime-actions">
<button id="runtimeEventsToggleBtn" class="icon-btn runtime-events-toggle is-on" type="button" title="Toast-уведомления включены для этого аккаунта" aria-label="Включить или выключить toast-уведомления">🔔</button>
<button id="runtimeEditorBtn" class="icon-btn" type="button" title="Открыть конструктор (Ctrl+Shift+E)" aria-label="Открыть конструктор"></button>
<button id="runtimeEditorBtn" class="icon-btn hidden" type="button" title="Открыть конструктор (Ctrl+Shift+E)" aria-label="Открыть конструктор"></button>
<button id="runtimeShortcutsBtn" class="icon-btn" type="button" title="Шорткаты" aria-label="Шорткаты"></button>
<button id="quickTimersBtn" class="icon-btn" type="button" title="Быстрая настройка таймеров" aria-label="Быстрая настройка таймеров"></button>
<button id="runtimeAdminSettingsBtn" class="icon-btn hidden" type="button" title="Настройки базы данных" aria-label="Настройки базы данных"></button>

View File

@@ -0,0 +1,55 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Хоккейный vMix</title>
<link rel="icon" type="image/svg+xml" href="__UI_BUILDER_ASSETS__/favicon.svg?v=__UI_BUILDER_ASSET_VERSION__">
<link rel="stylesheet" href="__UI_BUILDER_ASSETS__/styles.css?v=__UI_BUILDER_ASSET_VERSION__">
__UI_BUILDER_CUSTOM_STYLES__
<script>
window.UI_BUILDER_BOOT = {
api: "__UI_BUILDER_API__",
authApi: "__UI_BUILDER_AUTH_API__",
assets: "__UI_BUILDER_ASSETS__",
editorUrl: "__UI_BUILDER_EDITOR__",
runtimeUrl: "__UI_BUILDER_RUNTIME__",
editorHotkey: "__UI_BUILDER_EDITOR_HOTKEY__",
mode: "runtime"
};
</script>
</head>
<body class="runtime-entry">
<section id="runtimeView" class="runtime-view runtime-only">
<header class="runtime-topbar">
<div>
<div class="eyebrow">RUNTIME INTERFACE</div>
<div class="runtime-title-line"><strong id="runtimeTitle">Интерфейс</strong><small class="runtime-build-badge">BUILD 2026.08.19.28</small></div>
</div>
<div id="runtimeTabs" class="runtime-tabs"></div>
<div class="runtime-actions">
<button id="runtimeEventsToggleBtn" class="icon-btn runtime-events-toggle is-on" type="button" title="Toast-уведомления включены для этого аккаунта" aria-label="Включить или выключить toast-уведомления">🔔</button>
<button id="runtimeEditorBtn" class="icon-btn hidden" type="button" title="Открыть конструктор (Ctrl+Shift+E)" aria-label="Открыть конструктор"></button>
<button id="runtimeShortcutsBtn" class="icon-btn" type="button" title="Шорткаты" aria-label="Шорткаты"></button>
<button id="quickTimersBtn" class="icon-btn" type="button" title="Быстрая настройка таймеров" aria-label="Быстрая настройка таймеров"></button>
<button id="runtimeAdminSettingsBtn" class="icon-btn hidden" type="button" title="Настройки базы данных" aria-label="Настройки базы данных"></button>
<button id="runtimeLogoutBtn" class="icon-btn runtime-logout-btn" type="button" title="Выйти из аккаунта" aria-label="Выйти из аккаунта"></button>
<button id="closePreviewBtn" class="icon-btn btn-accent preview-only hidden" type="button" title="Вернуться в конструктор" aria-label="Вернуться в конструктор"></button>
</div>
</header>
<div id="runtimeViewport" class="runtime-viewport">
<div id="runtimeSizer" class="runtime-sizer">
<div id="runtimeStage" class="runtime-stage"></div>
</div>
</div>
<div id="runtimeButtonDock" class="runtime-button-dock hidden"></div>
</section>
<div id="modalHost"></div>
<div id="toast" class="toast"></div>
<datalist id="dataPathList"></datalist>
<script src="__UI_BUILDER_ASSETS__/app.js?v=__UI_BUILDER_ASSET_VERSION__"></script>
__UI_BUILDER_CUSTOM_SCRIPTS__
</body>
</html>

View File

@@ -173,7 +173,7 @@ h2 { margin: 0 0 10px; color: #cbd8ea; font-size: 12px; letter-spacing: .08em; t
/* Runtime and rendered controls */
.runtime-view { width: 100%; height: 100vh; min-height: 0; display: flex; flex-direction: column; overflow: hidden; background: #080d15; }
.runtime-topbar { min-height: 70px; display: grid; grid-template-columns: minmax(160px, .55fr) minmax(220px, 1fr) max-content; align-items: center; column-gap: 15px; row-gap: 8px; flex: 0 0 auto; padding: 9px 18px 0; border-bottom: 1px solid var(--line); background: rgba(8,13,21,.97); position: sticky; top: 0; z-index: 30; backdrop-filter: blur(16px); }
.runtime-topbar { min-height: 70px; display: grid; grid-template-columns: minmax(160px, .55fr) minmax(220px, 1fr) max-content; align-items: center; column-gap: 15px; row-gap: 8px; flex: 0 0 auto; padding: 9px 18px 0; border-bottom: 1px solid var(--line); background: #080d15; position: sticky; top: 0; z-index: 30; }
.runtime-actions { flex-wrap: nowrap; white-space: nowrap; }
.runtime-actions > .hockey-selected-chip { display: none !important; }
.runtime-topbar > div:first-child .eyebrow { font-size: 8px; }
@@ -182,7 +182,16 @@ h2 { margin: 0 0 10px; color: #cbd8ea; font-size: 12px; letter-spacing: .08em; t
.runtime-tab { min-height: 35px; padding: 0 13px; color: #9dafc7; background: #141f31; border: 1px solid var(--line); border-radius: 9px; }
.runtime-tab.active { color: #06130f; background: var(--accent); border-color: transparent; font-weight: 800; }
.runtime-viewport { min-height: 0; display: flex; align-items: center; justify-content: center; flex: 1 1 auto; overflow: hidden; padding: 10px; }
.runtime-stage { position: relative; margin: 0 auto; transform-origin: top left; overflow: hidden; box-shadow: 0 18px 70px rgba(0,0,0,.36); }
.runtime-stage {
position: relative;
margin: 0 auto;
transform-origin: top left;
overflow: hidden;
box-shadow: 0 18px 70px rgba(0,0,0,.36);
/* Keep browser-native font rasterization in runtime mode. */
text-rendering: optimizeLegibility;
-webkit-font-smoothing: auto;
}
.runtime-component { position: absolute; overflow: hidden; }
.ui-box { width: 100%; height: 100%; overflow: hidden; }
@@ -7208,3 +7217,37 @@ body.hockey-navigation-open .runtime-viewport.has-hockey-pbp { gap: 14px !import
border-color: rgba(255, 112, 112, .42);
background: rgba(126, 35, 47, .2);
}
/* BUILD71: side-specific penalty selection identifiers */
.hpd-team-penalty-card.is-preview-selected {
outline: 2px solid rgba(255, 255, 255, .72);
outline-offset: -2px;
}
.hpd-team-card-identifiers {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px 10px;
margin-top: 6px;
padding: 7px 9px;
border: 1px solid rgba(255, 255, 255, .12);
border-radius: 7px;
background: rgba(0, 0, 0, .16);
font-size: 10px;
line-height: 1.25;
}
.hpd-team-card-identifiers span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hpd-team-card-identifiers b {
display: block;
margin-bottom: 1px;
opacity: .58;
font-size: 8px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .035em;
}