diff --git a/.gitignore b/.gitignore index a15466d..595af71 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/app.py b/app.py index 963e97d..9976514 100644 --- a/app.py +++ b/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 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/") diff --git a/hockey_data/agent_bridge.py b/hockey_data/agent_bridge.py index 3d8df95..e87ce66 100644 --- a/hockey_data/agent_bridge.py +++ b/hockey_data/agent_bridge.py @@ -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) diff --git a/hockey_data/mapping_context.py b/hockey_data/mapping_context.py index 59ffa76..4027aa4 100644 --- a/hockey_data/mapping_context.py +++ b/hockey_data/mapping_context.py @@ -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"}, diff --git a/hockey_data/service.py b/hockey_data/service.py index 8d2882f..4b85992 100644 --- a/hockey_data/service.py +++ b/hockey_data/service.py @@ -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, diff --git a/hockey_data/static/admin-directories.css b/hockey_data/static/admin-directories.css index 65db14d..4e7b3ba 100644 --- a/hockey_data/static/admin-directories.css +++ b/hockey_data/static/admin-directories.css @@ -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%}} diff --git a/hockey_data/static/admin-directories.js b/hockey_data/static/admin-directories.js index 52d7e78..b689d68 100644 --- a/hockey_data/static/admin-directories.js +++ b/hockey_data/static/admin-directories.js @@ -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 @@
+
+ + +
+
@@ -2166,6 +2196,15 @@ +
+

Импорт готового Mapping

+ + + + + + Input ищется по key → названию → номеру. Поле должно совпасть по имени. +

Профили

${profileButtons}
@@ -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(); diff --git a/hockey_data/static/tournament-menu.js b/hockey_data/static/tournament-menu.js index 8f88cf7..386fb52 100644 --- a/hockey_data/static/tournament-menu.js +++ b/hockey_data/static/tournament-menu.js @@ -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(); diff --git a/tests/test_build69_runtime_entry_admin_editor.py b/tests/test_build69_runtime_entry_admin_editor.py new file mode 100644 index 0000000..78b2d27 --- /dev/null +++ b/tests/test_build69_runtime_entry_admin_editor.py @@ -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 diff --git a/tests/test_build70_crisp_runtime_rendering.py b/tests/test_build70_crisp_runtime_rendering.py new file mode 100644 index 0000000..b7e72a3 --- /dev/null +++ b/tests/test_build70_crisp_runtime_rendering.py @@ -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 diff --git a/tests/test_build71_penalty_side_identifiers.py b/tests/test_build71_penalty_side_identifiers.py new file mode 100644 index 0000000..56e00bd --- /dev/null +++ b/tests/test_build71_penalty_side_identifiers.py @@ -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 'Командное${event.teamPenalty ? "1" : "0"}' in JS + assert '.hpd-team-card-identifiers' in CSS diff --git a/tests/test_build72_portable_mapping_transfer.py b/tests/test_build72_portable_mapping_transfer.py new file mode 100644 index 0000000..dcb6528 --- /dev/null +++ b/tests/test_build72_portable_mapping_transfer.py @@ -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 diff --git a/ui_builder/integration.py b/ui_builder/integration.py index b164b82..78051b6 100644 --- a/ui_builder/integration.py +++ b/ui_builder/integration.py @@ -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) diff --git a/ui_builder/static/app.js b/ui_builder/static/app.js index 984a3e6..5135f98 100644 --- a/ui_builder/static/app.js +++ b/ui_builder/static/app.js @@ -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 = `
@@ -7165,9 +7264,18 @@ function hockeyPenaltyCard(component, event, runtime) { Командное удаление — без игрока + ${previewSelected ? ` +
+ ID удаления${escapeHtml(String(event.external_id || event.id || "—"))} + Player external ID${escapeHtml(playerIds.externalId || "—")} + Player DB ID${escapeHtml(playerIds.dbId || "—")} + Командное${event.teamPenalty ? "1" : "0"} +
+ ` : ""} +