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

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

19
.gitignore vendored
View File

@@ -40,7 +40,24 @@ pip-wheel-metadata/
!.env.example
!.env.sample
backups/
# Runtime settings. Do NOT ignore settings/ as a whole anymore:
# ui_builder_draft.json / ui_builder_published.json contain the operator UI,
# shortcuts, triggers, bottom-panel buttons and selectors and should be portable.
# Everything else under settings stays local unless explicitly allow-listed.
settings/*
!settings/README.md
!settings/ui_builder_draft.json
!settings/ui_builder_published.json
!settings/hockey_api.json
!settings/stat2tv_credentials.example.json
# Local secrets / machine-specific runtime settings (never commit).
settings/stat2tv_credentials.local.json
settings/editor_security.json
settings/backups/
settings/settings.json
settings/vmix_json.json
settings/vmix_functions.json
# Stat2TV / API diagnostic snapshots
hockey_data/snapshots/

30
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
BASE_DIR = Path(__file__).resolve().parent
BUILD_VERSION = "2026.08.19.26"
BUILD_VERSION = "2026.08.19.28"
# compatibility: BUILD_VERSION = "2026.08.19.24"
# compatibility: BUILD_VERSION = "2026.08.19.22"
load_dotenv(BASE_DIR / ".env.local", override=False)
@@ -66,23 +66,33 @@ async def hockey_mapping_auto_refresh_shutdown() -> None:
@app.middleware("http")
async def hockey_runtime_auth(request: Request, call_next):
"""Protect runtime and prevent stale operator-interface assets."""
if request.url.path == "/" or request.url.path.startswith("/khl-site"):
"""Protect runtime/editor and prevent stale operator-interface assets."""
path = request.url.path
editor_path = (
path == "/editor"
or path.startswith("/api/ui-builder/editor/")
or path.startswith("/api/ui-builder/auth/")
)
khl_admin_path = path.startswith("/khl-site")
protected_path = path == "/" or khl_admin_path or editor_path
if protected_path:
try:
user = await hockey_auth.optional_user(request)
except HTTPException as error:
return JSONResponse({"detail": error.detail}, status_code=error.status_code)
if user is None:
if request.url.path.startswith("/khl-site"):
return JSONResponse({"detail": "Требуется вход"}, status_code=401)
return RedirectResponse("/login?reason=expired", status_code=303)
if request.url.path.startswith("/khl-site") and not user.is_admin:
return JSONResponse({"detail": "Раздел КХЛ доступен только администратору"}, status_code=403)
if path in {"/", "/editor"}:
return RedirectResponse("/login?reason=expired", status_code=303)
return JSONResponse({"detail": "Требуется вход"}, status_code=401)
if (khl_admin_path or editor_path) and not user.is_admin:
if path == "/editor":
return RedirectResponse("/", status_code=303)
return JSONResponse({"detail": "Раздел доступен только администратору"}, status_code=403)
request.state.wfl_user = user
response = await call_next(request)
path = request.url.path
if (
path == "/"
path in {"/", "/editor"}
or path.startswith("/ui-builder-assets/")
or path.startswith("/hockey-assets/")
or path.startswith("/api/ui-builder/runtime/")

View File

@@ -1986,6 +1986,308 @@ class VmixAgentHub:
"inventory": inventory,
}
@staticmethod
def _portable_text(value: Any) -> str:
return " ".join(str(value or "").strip().casefold().split())
@classmethod
def _rebind_portable_mapping_fields(
cls,
fields: list[dict[str, Any]],
inventory: dict[str, Any],
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
"""Rebind saved Mapping targets to another vMix project inventory.
Resolution order is deliberately conservative: stable Input key first,
then a unique title, then a unique positional number as a legacy fallback.
The selected vMix field itself must also exist on the resolved Input.
"""
inputs = inventory.get("inputs") if isinstance(inventory, dict) and isinstance(inventory.get("inputs"), list) else []
by_key: dict[str, dict[str, Any]] = {}
by_title: dict[str, list[dict[str, Any]]] = {}
by_number: dict[str, list[dict[str, Any]]] = {}
for item in inputs:
if not isinstance(item, dict):
continue
key = str(item.get("key") or "").strip()
title = cls._portable_text(item.get("title"))
number = str(item.get("number") or "").strip()
if key:
by_key[key] = item
if title:
by_title.setdefault(title, []).append(item)
if number:
by_number.setdefault(number, []).append(item)
rebound: list[dict[str, Any]] = []
skipped: list[dict[str, Any]] = []
matched_by = {"key": 0, "title": 0, "number": 0}
seen: set[tuple[str, str, str]] = set()
for index, raw in enumerate((fields or [])[:2000]):
if not isinstance(raw, dict):
skipped.append({"index": index, "reason": "invalid_field"})
continue
data_key = str(raw.get("data_key") or "").strip()
vmix_field = str(raw.get("vmix_field") or "").strip()
if not data_key or not vmix_field:
skipped.append({"index": index, "data_key": data_key, "vmix_field": vmix_field, "reason": "missing_mapping_key"})
continue
source_key = str(raw.get("vmix_input_key") or "").strip()
source_title = str(raw.get("vmix_input_title") or "").strip()
source_number = str(raw.get("vmix_input_number") or "").strip()
target = by_key.get(source_key) if source_key else None
resolution = "key" if target is not None else ""
if target is None and source_title:
title_matches = by_title.get(cls._portable_text(source_title), [])
if len(title_matches) == 1:
target = title_matches[0]
resolution = "title"
elif len(title_matches) > 1:
skipped.append({"index": index, "data_key": data_key, "input": source_title, "vmix_field": vmix_field, "reason": "ambiguous_input_title"})
continue
if target is None and source_number:
number_matches = by_number.get(source_number, [])
if len(number_matches) == 1:
target = number_matches[0]
resolution = "number"
elif len(number_matches) > 1:
skipped.append({"index": index, "data_key": data_key, "input": source_number, "vmix_field": vmix_field, "reason": "ambiguous_input_number"})
continue
if target is None:
skipped.append({"index": index, "data_key": data_key, "input": source_title or source_key or source_number, "vmix_field": vmix_field, "reason": "input_not_found"})
continue
target_fields = target.get("fields") if isinstance(target.get("fields"), list) else []
exact = next((f for f in target_fields if isinstance(f, dict) and str(f.get("name") or "").strip() == vmix_field), None)
selected_name = vmix_field
if exact is None:
folded = cls._portable_text(vmix_field)
folded_matches = [f for f in target_fields if isinstance(f, dict) and cls._portable_text(f.get("name")) == folded]
if len(folded_matches) == 1:
selected_name = str(folded_matches[0].get("name") or "").strip()
else:
skipped.append({
"index": index, "data_key": data_key,
"input": str(target.get("title") or target.get("key") or target.get("number") or ""),
"vmix_field": vmix_field,
"reason": "field_not_found" if not folded_matches else "ambiguous_field",
})
continue
target_key = str(target.get("key") or "").strip()
target_title = str(target.get("title") or "").strip()
target_number = str(target.get("number") or "").strip()
dedupe = (data_key, target_key or target_title or target_number, selected_name)
if dedupe in seen:
skipped.append({"index": index, "data_key": data_key, "input": target_title, "vmix_field": selected_name, "reason": "duplicate_target"})
continue
seen.add(dedupe)
matched_by[resolution or "title"] = matched_by.get(resolution or "title", 0) + 1
rebound.append({
"graphic": str(raw.get("graphic") or "")[:100],
"data_key": data_key[:200],
"vmix_input_key": target_key[:128],
"vmix_input_number": target_number[:32],
"vmix_input_title": target_title[:300],
"vmix_field": selected_name[:300],
"field_type": str(raw.get("field_type") or "text")[:32],
"rule": dict(raw.get("rule") or {}) if isinstance(raw.get("rule"), dict) else {},
"enabled": bool(raw.get("enabled", True)),
"sort_order": len(rebound),
})
return rebound, {
"total": min(len(fields or []), 2000),
"mapped": len(rebound),
"skipped": len(skipped),
"matched_by": matched_by,
"skipped_items": skipped[:200],
}
@staticmethod
def _portable_profile_name(session: Any, desired: str, fallback: str) -> str:
base = str(desired or fallback or "Imported Mapping").strip()[:200] or "Imported Mapping"
if session.scalar(select(VmixMappingProfile).where(VmixMappingProfile.name == base)) is None:
return base
stem = base[:185]
for number in range(2, 1000):
candidate = f"{stem} ({number})"[:200]
if session.scalar(select(VmixMappingProfile).where(VmixMappingProfile.name == candidate)) is None:
return candidate
return f"{stem} {secrets.token_hex(3)}"[:200]
def _create_portable_profile_for_device(
self,
session: Any,
*,
device: VmixDevice,
fields: list[dict[str, Any]],
name: str,
description: str,
user: HockeyUser,
replace_existing: bool,
) -> tuple[VmixMappingProfile, dict[str, Any]]:
try:
inventory = json.loads(device.project_inventory_json or "{}")
except Exception:
inventory = {}
rebound, report = self._rebind_portable_mapping_fields(fields, inventory)
if not rebound:
raise HTTPException(status_code=409, detail="Не удалось сопоставить ни одной связи Mapping с выбранным vMix")
conflict = session.scalar(
select(VmixMappingProfile)
.where(and_(VmixMappingProfile.project_fingerprint == device.project_fingerprint, VmixMappingProfile.active.is_(True)))
.order_by(desc(VmixMappingProfile.updated_at), desc(VmixMappingProfile.id))
)
if conflict is not None:
if not replace_existing:
raise HTTPException(status_code=409, detail=f"Для этого vMix уже активен Mapping «{conflict.name}». Разрешите замену при переносе.")
conflict.active = False
conflict.updated_by = user.login
conflict.updated_at = _utcnow()
now = _utcnow()
profile = VmixMappingProfile(
name=self._portable_profile_name(session, name, "Imported Mapping"),
description=str(description or "")[:4000],
project_fingerprint=str(device.project_fingerprint or "")[:64],
inventory_json=device.project_inventory_json or "{}",
version=1,
active=True,
created_by=user.login,
updated_by=user.login,
created_at=now,
updated_at=now,
)
session.add(profile)
session.flush()
for index, item in enumerate(rebound):
session.add(VmixMappingField(
profile_id=profile.id,
graphic=str(item.get("graphic") or "")[:100],
data_key=str(item.get("data_key") or "")[:200],
vmix_input_key=str(item.get("vmix_input_key") or "")[:128],
vmix_input_number=str(item.get("vmix_input_number") or "")[:32],
vmix_input_title=str(item.get("vmix_input_title") or "")[:300],
vmix_field=str(item.get("vmix_field") or "")[:300],
field_type=str(item.get("field_type") or "text")[:32],
rule_json=json.dumps(dict(item.get("rule") or {}), ensure_ascii=False, separators=(",", ":"))[:12000],
enabled=bool(item.get("enabled", True)),
sort_order=index,
))
session.flush()
return profile, report
async def export_mapping_profile(self, profile_id: int) -> dict[str, Any]:
with self.database.session() as session:
profile = session.get(VmixMappingProfile, profile_id)
if profile is None:
raise HTTPException(status_code=404, detail="Mapping-профиль не найден")
payload = self._mapping_profile_payload(session, profile, include_inventory=True, include_fields=True)
fields = []
for row in payload.get("fields") or []:
fields.append({key: value for key, value in row.items() if key != "id"})
inventory = payload.get("inventory") if isinstance(payload.get("inventory"), dict) else {}
return {
"format": "hockey-vmix-mapping",
"schema_version": 1,
"exported_at": _utcnow().isoformat(),
"profile": {
"name": payload.get("name") or "",
"description": payload.get("description") or "",
"source_project_fingerprint": payload.get("project_fingerprint") or "",
"source_inventory": {
"input_count": inventory.get("input_count", 0),
"field_count": inventory.get("field_count", 0),
"vmix_version": inventory.get("vmix_version", ""),
},
"fields": fields,
},
}
async def copy_mapping_profile_to_device(self, profile_id: int, payload: Any, user: HockeyUser) -> dict[str, Any]:
device_id = self.normalise_device_id(payload.device_id)
with self.database.session() as session:
source = session.get(VmixMappingProfile, profile_id)
if source is None:
raise HTTPException(status_code=404, detail="Mapping-профиль не найден")
device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
if device is None or not device.project_fingerprint or not str(device.project_inventory_json or "").strip():
raise HTTPException(status_code=409, detail="Выбранный Agent ещё не передал структуру vMix")
source_payload = self._mapping_profile_payload(session, source, include_inventory=False, include_fields=True)
target_fingerprint = str(device.project_fingerprint or "")
if str(source.project_fingerprint or "") == target_fingerprint:
other = session.scalar(
select(VmixMappingProfile)
.where(and_(
VmixMappingProfile.project_fingerprint == target_fingerprint,
VmixMappingProfile.active.is_(True),
VmixMappingProfile.id != source.id,
))
.order_by(desc(VmixMappingProfile.updated_at), desc(VmixMappingProfile.id))
)
if other is not None and not bool(payload.replace_existing):
raise HTTPException(status_code=409, detail=f"Для этого vMix уже активен Mapping «{other.name}». Разрешите замену при переносе.")
if other is not None:
other.active = False
other.updated_by = user.login
other.updated_at = _utcnow()
source.active = True
source.updated_by = user.login
source.updated_at = _utcnow()
result_profile = self._mapping_profile_payload(session, source, include_inventory=True, include_fields=True)
report = {"total": len(source_payload.get("fields") or []), "mapped": len(source_payload.get("fields") or []), "skipped": 0, "matched_by": {"key": len(source_payload.get("fields") or []), "title": 0, "number": 0}, "skipped_items": [], "reused": True}
else:
fallback_name = f"{source.name} · {device.name or device.hostname or device.device_uuid}"
created, report = self._create_portable_profile_for_device(
session, device=device, fields=list(source_payload.get("fields") or []),
name=str(payload.name or "").strip() or fallback_name, description=source.description, user=user,
replace_existing=bool(payload.replace_existing),
)
result_profile = self._mapping_profile_payload(session, created, include_inventory=True, include_fields=True)
await self._broadcast_mapping_for_fingerprint(target_fingerprint)
applied = None
if bool(payload.apply_now):
applied = await self.apply_mapping_to_device(device_id, reason="portable_mapping_copy")
return {"ok": True, "profile": result_profile, "report": report, "applied": applied}
async def import_mapping_profile_to_device(self, payload: Any, user: HockeyUser) -> dict[str, Any]:
device_id = self.normalise_device_id(payload.device_id)
document = dict(payload.document or {})
if document.get("format") not in {None, "", "hockey-vmix-mapping"}:
raise HTTPException(status_code=422, detail="Это не файл Hockey vMix Mapping")
try:
schema_version = int(document.get("schema_version") or 1)
except (TypeError, ValueError):
raise HTTPException(status_code=422, detail="Некорректная версия файла Mapping")
if schema_version != 1:
raise HTTPException(status_code=422, detail="Версия файла Mapping пока не поддерживается")
profile_doc = document.get("profile") if isinstance(document.get("profile"), dict) else document
fields = profile_doc.get("fields") if isinstance(profile_doc, dict) and isinstance(profile_doc.get("fields"), list) else []
if not fields:
raise HTTPException(status_code=422, detail="В файле Mapping нет связей")
with self.database.session() as session:
device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
if device is None or not device.project_fingerprint or not str(device.project_inventory_json or "").strip():
raise HTTPException(status_code=409, detail="Выбранный Agent ещё не передал структуру vMix")
target_fingerprint = str(device.project_fingerprint or "")
imported_name = str(payload.name or "").strip() or str(profile_doc.get("name") or "Imported Mapping").strip()
created, report = self._create_portable_profile_for_device(
session, device=device, fields=fields, name=imported_name,
description=str(profile_doc.get("description") or ""), user=user,
replace_existing=bool(payload.replace_existing),
)
result_profile = self._mapping_profile_payload(session, created, include_inventory=True, include_fields=True)
await self._broadcast_mapping_for_fingerprint(target_fingerprint)
applied = None
if bool(payload.apply_now):
applied = await self.apply_mapping_to_device(device_id, reason="portable_mapping_import")
return {"ok": True, "profile": result_profile, "report": report, "applied": applied}
async def list_mapping_profiles(self) -> dict[str, Any]:
with self.database.session() as session:
rows = list(session.scalars(select(VmixMappingProfile).order_by(desc(VmixMappingProfile.updated_at), VmixMappingProfile.name)))
@@ -2304,6 +2606,21 @@ class MappingFieldsReplacePayload(BaseModel):
fields: list[MappingFieldPayload] = Field(default_factory=list, max_length=2000)
class MappingProfileCopyPayload(BaseModel):
device_id: str = Field(min_length=6, max_length=128)
name: str = Field(default="", max_length=200)
replace_existing: bool = False
apply_now: bool = True
class MappingPortableImportPayload(BaseModel):
device_id: str = Field(min_length=6, max_length=128)
document: dict[str, Any] = Field(default_factory=dict)
name: str = Field(default="", max_length=200)
replace_existing: bool = False
apply_now: bool = True
class ContextVariablePayload(BaseModel):
key: str = Field(min_length=1, max_length=128)
label: str = Field(min_length=1, max_length=200)
@@ -2523,6 +2840,18 @@ def create_hockey_agent_router(
async def mapping_profile_create(payload: MappingProfileCreatePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
return await hub.create_mapping_profile(payload, user)
@router.get("/api/hockey/admin/vmix-mapping/profiles/{profile_id}/export", dependencies=admin)
async def mapping_profile_export(profile_id: int) -> dict[str, Any]:
return await hub.export_mapping_profile(profile_id)
@router.post("/api/hockey/admin/vmix-mapping/profiles/{profile_id}/copy-to-device", dependencies=admin)
async def mapping_profile_copy_to_device(profile_id: int, payload: MappingProfileCopyPayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
return await hub.copy_mapping_profile_to_device(profile_id, payload, user)
@router.post("/api/hockey/admin/vmix-mapping/import", dependencies=admin)
async def mapping_profile_import(payload: MappingPortableImportPayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
return await hub.import_mapping_profile_to_device(payload, user)
@router.put("/api/hockey/admin/vmix-mapping/profiles/{profile_id}", dependencies=admin)
async def mapping_profile_update(profile_id: int, payload: MappingProfileUpdatePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
return await hub.update_mapping_profile(profile_id, payload, user)

View File

@@ -63,8 +63,19 @@ DEFAULT_CONTEXT_VARIABLES: tuple[dict[str, Any], ...] = (
{"key": "selected_referee_id", "label": "Выбранный судья", "category": "Выделенные", "entity_type": "referee", "scope": "match", "source_type": "selection"},
{"key": "selected_goal_id", "label": "Выбранный гол", "category": "Выделенные", "entity_type": "goal", "scope": "match", "source_type": "selection"},
{"key": "selected_penalty_id", "label": "Выбранное удаление", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"},
{"key": "selected_penalty_player_id", "label": "Игрок выбранного удаления", "category": "Выделенные", "entity_type": "player", "scope": "match", "source_type": "selection"},
{"key": "selected_penalty_player_id", "label": "Игрок выбранного удаления (external ID)", "category": "Выделенные", "entity_type": "player", "scope": "match", "source_type": "selection"},
{"key": "selected_penalty_player_db_id", "label": "Игрок выбранного удаления (DB ID)", "category": "Выделенные", "entity_type": "player", "scope": "match", "source_type": "selection"},
{"key": "selected_penalty_team_penalty", "label": "Выбранное удаление командное (1/0)", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection", "value_type": "text"},
{"key": "selected_penalty_team_id", "label": "Команда выбранного удаления", "category": "Выделенные", "entity_type": "team", "scope": "match", "source_type": "selection"},
{"key": "selected_home_penalty_id", "label": "Удаление левой команды — ID удаления", "category": "Удаления — левая команда", "entity_type": "penalty", "scope": "match", "source_type": "selection"},
{"key": "selected_home_penalty_player_id", "label": "Удаление левой команды — игрок external ID", "category": "Удаления — левая команда", "entity_type": "player", "scope": "match", "source_type": "selection"},
{"key": "selected_home_penalty_player_db_id", "label": "Удаление левой команды — игрок DB ID", "category": "Удаления — левая команда", "entity_type": "player", "scope": "match", "source_type": "selection"},
{"key": "selected_home_penalty_team_penalty", "label": "Удаление левой команды — командное (1/0)", "category": "Удаления — левая команда", "entity_type": "penalty", "scope": "match", "source_type": "selection", "value_type": "text"},
{"key": "selected_away_penalty_id", "label": "Удаление правой команды — ID удаления", "category": "Удаления — правая команда", "entity_type": "penalty", "scope": "match", "source_type": "selection"},
{"key": "selected_away_penalty_player_id", "label": "Удаление правой команды — игрок external ID", "category": "Удаления — правая команда", "entity_type": "player", "scope": "match", "source_type": "selection"},
{"key": "selected_away_penalty_player_db_id", "label": "Удаление правой команды — игрок DB ID", "category": "Удаления — правая команда", "entity_type": "player", "scope": "match", "source_type": "selection"},
{"key": "selected_away_penalty_team_penalty", "label": "Удаление правой команды — командное (1/0)", "category": "Удаления — правая команда", "entity_type": "penalty", "scope": "match", "source_type": "selection", "value_type": "text"},
{"key": "selected_penalty_infraction_id", "label": "Нарушение выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"},
{"key": "selected_penalty_preset_id", "label": "Длительность выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"},
{"key": "selected_penalty_side", "label": "Сторона выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"},

View File

@@ -4866,6 +4866,7 @@ class HockeyDataService:
{
"id": player.external_id,
"external_id": player.external_id,
"db_id": player.id,
"side": roster.side,
"team_id": roster.team_external_id,
"number": roster.number,

View File

@@ -1590,3 +1590,12 @@
.hockey-period-status-row input { width:100%; min-height:36px; box-sizing:border-box; padding:0 10px; color:#eef7ff; border:1px solid #304b67; border-radius:8px; outline:none; background:#101f31; font-size:11px; font-weight:720; }
.hockey-period-status-row input:focus { border-color:#48dfbd; box-shadow:0 0 0 3px rgba(72,223,189,.09); }
@media (max-width:760px) { .hockey-period-status-row { grid-template-columns:90px 1fr; } .hockey-period-status-row.is-head span:nth-child(3) { display:none; } .hockey-period-status-row input:last-child { grid-column:2; } }
/* BUILD72 — portable vMix Mapping transfer/import */
.hockey-map-transfer-inline{display:grid;grid-template-columns:minmax(190px,1fr) auto;gap:6px;align-items:center}
.hockey-map-transfer-inline select{min-width:190px}
.hockey-mapping-import{border-top:1px solid rgba(57,80,104,.7);padding-top:12px;margin-top:12px}
.hockey-mapping-import input[type="file"]{height:auto;min-height:38px;padding:7px;background:#0b1723;color:#9fb5c9}
.hockey-mapping-import small{display:block;color:#6f879d;font-size:8px;line-height:1.35}
.hockey-mapping-import .hockey-directory-check{margin:2px 0}
@media(max-width:900px){.hockey-map-transfer-inline{grid-template-columns:1fr}.hockey-map-transfer-inline select{min-width:0;width:100%}}

View File

@@ -86,6 +86,31 @@
return data;
}
function downloadJson(filename, data) {
const safe = String(filename || "mapping.json").replace(/[\\/:*?"<>|]+/g, "_");
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url; anchor.download = safe; document.body.appendChild(anchor); anchor.click(); anchor.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function mappingTransferReport(result, action = "Mapping перенесён") {
const report = result?.report || {};
const mapped = Number(report.mapped || 0);
const total = Number(report.total || mapped);
const skipped = Number(report.skipped || 0);
const by = report.matched_by || {};
const methods = [
Number(by.key || 0) ? `key ${Number(by.key || 0)}` : "",
Number(by.title || 0) ? `название ${Number(by.title || 0)}` : "",
Number(by.number || 0) ? `номер ${Number(by.number || 0)}` : "",
].filter(Boolean).join(" · ");
return `${action}: ${mapped}/${total} связей${skipped ? ` · пропущено ${skipped}` : ""}${methods ? ` · ${methods}` : ""}`;
}
function selectedContext() {
const data = window.UIBuilderRuntime?.getData?.() || {};
const game = data.hockey?.selected_game || null;
@@ -2148,6 +2173,11 @@
<div class="hockey-map-maintenance">
<select data-map-refresh-device><option value="">Обновить структуру из Agent…</option>${usableDevices.map((item) => `<option value="${escapeHtml(item.device_id)}">${escapeHtml(item.name || item.device_id)} · ${Number(item.input_count || 0)}/${Number(item.field_count || 0)}</option>`).join("")}</select>
<button type="button" data-map-refresh>Считать vMix заново</button>
<div class="hockey-map-transfer-inline">
<select data-map-copy-device><option value="">Перенести на другой Agent…</option>${usableDevices.map((item) => `<option value="${escapeHtml(item.device_id)}">${escapeHtml(item.name || item.device_id)} · ${Number(item.input_count || 0)} Inputs</option>`).join("")}</select>
<button type="button" data-map-copy-to-device>Копировать на Agent</button>
</div>
<button type="button" data-map-export>Экспорт JSON</button>
<button type="button" class="danger" data-map-delete>Удалить профиль</button>
</div>
<div><label class="hockey-directory-check"><input type="checkbox" data-mapping-active ${profile.active !== false ? "checked" : ""}><span>Профиль активен</span></label><button type="button" data-map-apply-now title="Применяются сохранённые на сервере связи">Применить весь Mapping</button><button type="button" class="is-accent" data-map-save>Сохранить mapping</button></div>
@@ -2166,6 +2196,15 @@
<label>Описание<input name="description" placeholder="Основной графический пакет"></label>
<button type="submit" class="is-accent" ${usableDevices.length ? "" : "disabled"}>Создать из vMix</button>
</form>
<form class="hockey-mapping-create hockey-mapping-import" data-mapping-import>
<h3>Импорт готового Mapping</h3>
<label>Файл Mapping<input type="file" name="file" accept="application/json,.json" required></label>
<label>Применить к Agent<select name="device_id" required><option value="">Выберите устройство</option>${usableDevices.map((item) => `<option value="${escapeHtml(item.device_id)}">${escapeHtml(item.name || item.device_id)} · ${Number(item.input_count || 0)} Inputs</option>`).join("")}</select></label>
<label>Новое название<input name="name" placeholder="Оставить название из файла"></label>
<label class="hockey-directory-check"><input type="checkbox" name="replace_existing"><span>Заменить активный Mapping этого vMix</span></label>
<button type="submit" ${usableDevices.length ? "" : "disabled"}>Загрузить и применить</button>
<small>Input ищется по key → названию → номеру. Поле должно совпасть по имени.</small>
</form>
<h3>Профили</h3>
<div class="hockey-mapping-profiles">${profileButtons}</div>
</aside>
@@ -2390,6 +2429,31 @@
} catch (error) { setStatus(error.message, true); }
renderMapping();
});
modal.querySelector("[data-mapping-import]")?.addEventListener("submit", async (event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const file = form.get("file");
if (!(file instanceof File) || !file.size) return setStatus("Выберите JSON-файл Mapping.", true), renderMapping();
if (file.size > 8 * 1024 * 1024) return setStatus("Файл Mapping слишком большой.", true), renderMapping();
try {
const document = JSON.parse(await file.text());
const result = await request("/api/hockey/admin/vmix-mapping/import", {
method: "POST",
body: JSON.stringify({
device_id: String(form.get("device_id") || ""),
name: String(form.get("name") || ""),
replace_existing: Boolean(form.get("replace_existing")),
apply_now: true,
document,
}),
});
await loadMapping(result.profile?.id || null);
state.mappingSelectedInputKey = ""; state.mappingVmixScrollTop = 0;
setStatus(mappingTransferReport(result, `Mapping «${result.profile?.name || ""}» импортирован`), Number(result.report?.skipped || 0) > 0);
} catch (error) { setStatus(error.message || "Не удалось прочитать Mapping JSON", true); }
renderMapping();
});
modal.querySelector("[data-map-input-picker]")?.addEventListener("change", (event) => {
state.mappingSelectedInputKey = event.currentTarget.value;
state.mappingTargetField = "";
@@ -2646,6 +2710,36 @@
} catch (error) { setStatus(error.message, true); }
renderMapping();
});
modal.querySelector("[data-map-export]")?.addEventListener("click", async () => {
if (!state.mappingActiveProfile) return;
try {
const document = await request(`/api/hockey/admin/vmix-mapping/profiles/${state.mappingActiveProfile.id}/export`);
const base = String(state.mappingActiveProfile.name || "mapping").trim().replace(/\s+/g, "_");
downloadJson(`${base}.hockey-mapping.json`, document);
setStatus(`Mapping «${state.mappingActiveProfile.name}» экспортирован.`);
} catch (error) { setStatus(error.message, true); }
renderMapping();
});
modal.querySelector("[data-map-copy-to-device]")?.addEventListener("click", async () => {
if (!state.mappingActiveProfile) return;
const deviceId = modal.querySelector("[data-map-copy-device]")?.value || "";
if (!deviceId) return setStatus("Выберите Agent, на который нужно перенести Mapping.", true), renderMapping();
const target = (state.mappingDevices?.devices || []).find((item) => item.device_id === deviceId);
const replaceExisting = Boolean(target?.mapping && target.mapping.id !== state.mappingActiveProfile.id)
? window.confirm(`На «${target?.name || deviceId}» уже назначен Mapping «${target.mapping.name}». Заменить его новой копией?`)
: false;
if (target?.mapping && target.mapping.id !== state.mappingActiveProfile.id && !replaceExisting) return;
try {
const result = await request(`/api/hockey/admin/vmix-mapping/profiles/${state.mappingActiveProfile.id}/copy-to-device`, {
method: "POST",
body: JSON.stringify({ device_id: deviceId, replace_existing: replaceExisting, apply_now: true }),
});
await loadMapping(result.profile?.id || state.mappingActiveProfile.id);
state.mappingSelectedInputKey = ""; state.mappingVmixScrollTop = 0;
setStatus(mappingTransferReport(result, `Mapping перенесён на ${target?.name || deviceId}`), Number(result.report?.skipped || 0) > 0);
} catch (error) { setStatus(error.message, true); }
renderMapping();
});
modal.querySelector("[data-map-apply-now]")?.addEventListener("click", async () => {
const deviceId = state.mappingTestDeviceId || modal.querySelector("[data-map-test-device]")?.value || "";
if (!deviceId) return setStatus("Выберите online Agent для применения Mapping.", true), renderMapping();

View File

@@ -317,6 +317,9 @@
}
state.currentUser = user;
state.isAdmin = Boolean(user?.is_admin);
const editorButton = document.getElementById("runtimeEditorBtn");
editorButton?.classList.toggle("hidden", !state.isAdmin);
document.body.dataset.hockeyAdmin = state.isAdmin ? "1" : "0";
const currentAccountId = String(user?.id || "").trim();
if (!currentAccountId) return;
const previousAccountId = String(localStorage.getItem(storage.account) || "").trim();

View File

@@ -0,0 +1,45 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
RUNTIME = (ROOT / "ui_builder" / "static" / "runtime.html").read_text(encoding="utf-8")
EDITOR = (ROOT / "ui_builder" / "static" / "index.html").read_text(encoding="utf-8")
INTEGRATION = (ROOT / "ui_builder" / "integration.py").read_text(encoding="utf-8")
APP_JS = (ROOT / "ui_builder" / "static" / "app.js").read_text(encoding="utf-8")
TOURNAMENT_JS = (ROOT / "hockey_data" / "static" / "tournament-menu.js").read_text(encoding="utf-8")
APP_PY = (ROOT / "app.py").read_text(encoding="utf-8")
def test_runtime_has_no_builder_dom():
assert 'id="editor"' not in RUNTIME
assert 'id="componentLibrary"' not in RUNTIME
assert 'id="canvasStage"' not in RUNTIME
assert 'id="inspector"' not in RUNTIME
assert 'class="topbar editor-only"' not in RUNTIME
assert 'id="runtimeView"' in RUNTIME
assert 'runtime-view runtime-only hidden' not in RUNTIME
def test_editor_keeps_full_builder():
assert 'id="editor"' in EDITOR
assert 'id="componentLibrary"' in EDITOR
assert 'id="canvasStage"' in EDITOR
assert 'id="inspector"' in EDITOR
def test_integration_uses_separate_runtime_template():
assert 'runtime_template = (assets_dir / "runtime.html")' in INTEGRATION
assert 'template = editor_template if mode == "editor" else runtime_template' in INTEGRATION
def test_editor_button_is_admin_only_and_hotkey_respects_it():
assert 'id="runtimeEditorBtn" class="icon-btn hidden"' in RUNTIME
assert 'editorButton?.classList.toggle("hidden", !state.isAdmin);' in TOURNAMENT_JS
assert 'document.body.dataset.hockeyAdmin = state.isAdmin ? "1" : "0";' in TOURNAMENT_JS
assert 'el.runtimeEditorBtn?.classList.contains("hidden")' in APP_JS
def test_editor_server_paths_are_admin_protected():
assert 'path == "/editor"' in APP_PY
assert 'path.startswith("/api/ui-builder/editor/")' in APP_PY
assert 'path.startswith("/api/ui-builder/auth/")' in APP_PY
assert '(khl_admin_path or editor_path) and not user.is_admin' in APP_PY

View File

@@ -0,0 +1,30 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
JS = (ROOT / "ui_builder" / "static" / "app.js").read_text(encoding="utf-8")
CSS = (ROOT / "ui_builder" / "static" / "styles.css").read_text(encoding="utf-8")
APP = (ROOT / "app.py").read_text(encoding="utf-8")
def test_build70_prefers_css_zoom_for_runtime_canvas():
assert 'CSS.supports("zoom", "1")' in JS
assert 'el.runtimeStage.style.zoom = String(scale);' in JS
assert 'el.runtimeStage.style.transform = "none";' in JS
def test_build70_fallback_does_not_force_translate3d_layer():
start = JS.index('const canUseCssZoom')
snippet = JS[start:start + 1500]
assert 'style.transform = `translate3d' not in snippet
assert 'el.runtimeStage.style.transform = `scale(${scale})`;' in snippet
def test_build70_runtime_topbar_has_no_backdrop_blur():
start = CSS.index('.runtime-topbar {')
runtime_topbar = CSS[start:CSS.index('}', start) + 1]
assert 'backdrop-filter' not in runtime_topbar
assert 'background: #080d15' in runtime_topbar
def test_build70_version():
assert 'BUILD_VERSION = "2026.08.19.28"' in APP

View File

@@ -0,0 +1,52 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
JS = (ROOT / "ui_builder/static/app.js").read_text(encoding="utf-8")
MAPPING = (ROOT / "hockey_data/mapping_context.py").read_text(encoding="utf-8")
SERVICE = (ROOT / "hockey_data/service.py").read_text(encoding="utf-8")
CSS = (ROOT / "ui_builder/static/styles.css").read_text(encoding="utf-8")
def test_player_payload_exposes_database_id_without_replacing_external_id():
assert '"id": player.external_id' in SERVICE
assert '"external_id": player.external_id' in SERVICE
assert '"db_id": player.id' in SERVICE
def test_mapping_has_independent_home_and_away_penalty_identifiers():
for key in (
"selected_home_penalty_id",
"selected_home_penalty_player_id",
"selected_home_penalty_player_db_id",
"selected_home_penalty_team_penalty",
"selected_away_penalty_id",
"selected_away_penalty_player_id",
"selected_away_penalty_player_db_id",
"selected_away_penalty_team_penalty",
"selected_penalty_player_db_id",
"selected_penalty_team_penalty",
):
assert f'"key": "{key}"' in MAPPING
def test_penalty_preview_writes_side_specific_context_and_team_flag():
for key in (
"selected_home_penalty_id",
"selected_home_penalty_player_db_id",
"selected_home_penalty_team_penalty",
"selected_away_penalty_id",
"selected_away_penalty_player_db_id",
"selected_away_penalty_team_penalty",
):
assert f"{key}:" in JS
assert 'selected_penalty_team_penalty: detail.team_penalty ? "1" : "0"' in JS
assert 'selected_penalty_player_db_id: detail.player_db_id' in JS
def test_preview_selection_is_kept_per_side_and_visible():
assert 'selectedPreviewEventIds: { home: "", away: "" }' in JS
assert 'board.selectedPreviewEventIds[side] = event.id' in JS
assert 'hockeyPenaltyIsPreviewSelected(board, event)' in JS
assert 'Player DB ID' in JS
assert 'Командное</b>${event.teamPenalty ? "1" : "0"}' in JS
assert '.hpd-team-card-identifiers' in CSS

View File

@@ -0,0 +1,145 @@
from __future__ import annotations
import asyncio
import json
from pathlib import Path
from types import SimpleNamespace
from sqlalchemy import select
from hockey_data.agent_bridge import VmixAgentHub
from hockey_data.auth_bridge import HockeyUser
from hockey_data.models import VmixDevice, VmixMappingField, VmixMappingProfile
from tests.support import LocalTestDatabase
ROOT = Path(__file__).resolve().parents[1]
JS = (ROOT / "hockey_data/static/admin-directories.js").read_text(encoding="utf-8")
CSS = (ROOT / "hockey_data/static/admin-directories.css").read_text(encoding="utf-8")
BRIDGE = (ROOT / "hockey_data/agent_bridge.py").read_text(encoding="utf-8")
def test_portable_rebind_uses_title_when_input_key_changed() -> None:
fields = [{
"graphic": "score",
"data_key": "game.home_score",
"vmix_input_key": "old-key",
"vmix_input_number": "5",
"vmix_input_title": "SCORE BUG",
"vmix_field": "HomeScore.Text",
"field_type": "text",
"enabled": True,
"rule": {"enabled": True, "operator": "eq", "right_value": "1"},
}]
inventory = {"inputs": [{
"key": "new-key",
"number": "17",
"title": "SCORE BUG",
"type": "GT",
"fields": [{"name": "HomeScore.Text", "type": "text", "index": "0"}],
}]}
rebound, report = VmixAgentHub._rebind_portable_mapping_fields(fields, inventory)
assert report["mapped"] == 1
assert report["skipped"] == 0
assert report["matched_by"]["title"] == 1
assert rebound[0]["vmix_input_key"] == "new-key"
assert rebound[0]["vmix_input_number"] == "17"
assert rebound[0]["vmix_input_title"] == "SCORE BUG"
assert rebound[0]["vmix_field"] == "HomeScore.Text"
assert rebound[0]["rule"]["operator"] == "eq"
def test_portable_rebind_reports_missing_field_without_guessing() -> None:
fields = [{
"data_key": "game.clock",
"vmix_input_key": "old-key",
"vmix_input_title": "SCORE BUG",
"vmix_field": "Clock.Text",
"field_type": "text",
"enabled": True,
}]
inventory = {"inputs": [{
"key": "new-key",
"number": "2",
"title": "SCORE BUG",
"fields": [{"name": "Period.Text", "type": "text", "index": "0"}],
}]}
rebound, report = VmixAgentHub._rebind_portable_mapping_fields(fields, inventory)
assert rebound == []
assert report["mapped"] == 0
assert report["skipped"] == 1
assert report["skipped_items"][0]["reason"] == "field_not_found"
def test_copy_profile_creates_target_specific_mapping_and_keeps_source(tmp_path: Path) -> None:
database = LocalTestDatabase(tmp_path / "portable-mapping.sqlite3")
database.create_all()
hub = VmixAgentHub(database) # type: ignore[arg-type]
user = HockeyUser(id="admin-1", login="admin", display_name="Admin")
source_inventory = {
"fingerprint": "a" * 64,
"inputs": [{
"key": "source-score-key", "number": "3", "title": "SCORE BUG", "type": "GT",
"fields": [{"name": "HomeScore.Text", "type": "text", "index": "0"}],
}],
"input_count": 1, "field_count": 1,
}
target_inventory = {
"fingerprint": "b" * 64,
"inputs": [{
"key": "target-score-key", "number": "11", "title": "SCORE BUG", "type": "GT",
"fields": [{"name": "HomeScore.Text", "type": "text", "index": "4"}],
}],
"input_count": 1, "field_count": 1,
}
with database.session() as session:
source = VmixMappingProfile(
name="KHL MAIN", description="portable", project_fingerprint="a" * 64,
inventory_json=json.dumps(source_inventory), version=7, active=True,
created_by="admin", updated_by="admin",
)
session.add(source)
session.flush()
source_id = source.id
session.add(VmixMappingField(
profile_id=source.id, graphic="score", data_key="game.home_score",
vmix_input_key="source-score-key", vmix_input_number="", vmix_input_title="SCORE BUG",
vmix_field="HomeScore.Text", field_type="text", rule_json="{}", enabled=True, sort_order=0,
))
session.add(VmixDevice(
device_uuid="GFX-PORTABLE-02", device_secret_hash="x" * 64, name="GFX 2",
project_fingerprint="b" * 64, project_inventory_json=json.dumps(target_inventory),
project_input_count=1, project_field_count=1, vmix_connected=True,
))
payload = SimpleNamespace(device_id="GFX-PORTABLE-02", name="", replace_existing=False, apply_now=False)
result = asyncio.run(hub.copy_mapping_profile_to_device(source_id, payload, user))
assert result["report"]["mapped"] == 1
assert result["report"]["matched_by"]["title"] == 1
assert result["profile"]["project_fingerprint"] == "b" * 64
assert result["profile"]["fields"][0]["vmix_input_key"] == "target-score-key"
assert result["profile"]["fields"][0]["vmix_input_title"] == "SCORE BUG"
with database.session() as session:
profiles = list(session.scalars(select(VmixMappingProfile).order_by(VmixMappingProfile.id)))
assert len(profiles) == 2
assert profiles[0].id == source_id and profiles[0].active is True
assert profiles[0].project_fingerprint == "a" * 64
assert profiles[1].active is True
assert profiles[1].project_fingerprint == "b" * 64
def test_admin_ui_has_export_import_and_copy_to_agent_controls() -> None:
assert "data-map-export" in JS
assert "data-map-copy-to-device" in JS
assert "data-mapping-import" in JS
assert "/copy-to-device" in JS
assert "/api/hockey/admin/vmix-mapping/import" in JS
assert "hockey-mapping.json" in JS
assert "Input ищется по key → названию → номеру" in JS
assert "portable vMix Mapping transfer/import" in CSS
assert '"/api/hockey/admin/vmix-mapping/profiles/{profile_id}/export"' in BRIDGE
assert '"/api/hockey/admin/vmix-mapping/import"' in BRIDGE

View File

@@ -89,7 +89,8 @@ def install_ui_builder(
)
)
template = (assets_dir / "index.html").read_text(encoding="utf-8")
editor_template = (assets_dir / "index.html").read_text(encoding="utf-8")
runtime_template = (assets_dir / "runtime.html").read_text(encoding="utf-8")
asset_digest = hashlib.sha256()
for asset_name in ("app.js", "styles.css"):
asset_path = assets_dir / asset_name
@@ -101,6 +102,9 @@ def install_ui_builder(
def render_page(mode: str) -> HTMLResponse:
page_api = editor_api if mode == "editor" else runtime_api
# Runtime has its own HTML entry point and contains no Builder DOM at all.
# This prevents the visual editor from flashing during normal page load.
template = editor_template if mode == "editor" else runtime_template
html = (
template.replace("__UI_BUILDER_ASSETS__", assets_url.rstrip("/"))
.replace("__UI_BUILDER_ASSET_VERSION__", asset_version)

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;
}