экспорт маппинга
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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%}}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user