маппинг

This commit is contained in:
2026-08-19 17:44:24 +03:00
parent 614a61168a
commit 14067f082c
10 changed files with 632 additions and 40 deletions

View File

@@ -12,7 +12,7 @@ from typing import Any, Callable
from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket, WebSocketDisconnect, status
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import and_, desc, select
from sqlalchemy import and_, delete, desc, select
from .auth_bridge import HockeyUser
from .database import HockeyDatabase
@@ -1879,6 +1879,20 @@ class VmixAgentHub:
]
return payload
def _mapping_device_display_payload(self, session: Any, profile: VmixMappingProfile) -> dict[str, Any]:
source_id = self._runtime_mapping_source_id(profile)
if source_id:
source = session.get(VmixMappingProfile, source_id)
if source is not None:
return {
"id": source.id,
"name": source.name,
"version": source.version,
"runtime_profile_id": profile.id,
"source_profile_id": source.id,
}
return {"id": profile.id, "name": profile.name, "version": profile.version, "source_profile_id": profile.id}
async def list_mapping_devices(self) -> dict[str, Any]:
with self.database.session() as session:
rows = list(session.scalars(select(VmixDevice).order_by(desc(VmixDevice.last_seen_at))))
@@ -1897,7 +1911,7 @@ class VmixAgentHub:
"input_count": row.project_input_count,
"field_count": row.project_field_count,
"scanned_at": row.project_scanned_at.isoformat() if row.project_scanned_at else "",
"mapping": ({"id": profile.id, "name": profile.name, "version": profile.version} if profile else None),
"mapping": (self._mapping_device_display_payload(session, profile) if profile else None),
})
return {"devices": items}
@@ -2182,6 +2196,198 @@ class VmixAgentHub:
session.flush()
return profile, report
@staticmethod
def _runtime_mapping_profile_name(source_profile_id: int, target_fingerprint: str) -> str:
"""Deterministic hidden profile used only to adapt one saved config to another vMix."""
safe_fp = re.sub(r"[^a-zA-Z0-9]+", "", str(target_fingerprint or ""))[:32] or "project"
return f"__AUTO_MAPPING__{int(source_profile_id)}__{safe_fp}"[:200]
@staticmethod
def _runtime_mapping_source_id(profile: VmixMappingProfile | None) -> int:
if profile is None:
return 0
match = re.match(r"^__AUTO_MAPPING__(\d+)__", str(profile.name or ""))
return int(match.group(1)) if match else 0
def _upsert_runtime_mapping_for_device(
self,
session: Any,
*,
source: VmixMappingProfile,
source_fields: list[dict[str, Any]],
device: VmixDevice,
user: HockeyUser,
) -> tuple[VmixMappingProfile, dict[str, Any]]:
"""Create/update a hidden rebinding of a saved config for one concrete vMix project."""
try:
inventory = json.loads(device.project_inventory_json or "{}")
except Exception:
inventory = {}
rebound, report = self._rebind_portable_mapping_fields(source_fields, inventory)
if not rebound:
raise HTTPException(status_code=409, detail="Не удалось сопоставить ни одной связи выбранного Mapping с вашим vMix")
target_fingerprint = str(device.project_fingerprint or "")[:64]
runtime_name = self._runtime_mapping_profile_name(source.id, target_fingerprint)
runtime = session.scalar(select(VmixMappingProfile).where(VmixMappingProfile.name == runtime_name))
now = _utcnow()
# Only one concrete mapping can drive a vMix project at a time.
for other in session.scalars(
select(VmixMappingProfile).where(and_(
VmixMappingProfile.project_fingerprint == target_fingerprint,
VmixMappingProfile.active.is_(True),
))
):
if runtime is not None and other.id == runtime.id:
continue
other.active = False
other.updated_by = user.login
other.updated_at = now
if runtime is None:
runtime = VmixMappingProfile(
name=runtime_name,
description=f"Техническая адаптация Mapping #{source.id}: {source.name}"[:4000],
project_fingerprint=target_fingerprint,
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(runtime)
session.flush()
else:
runtime.description = f"Техническая адаптация Mapping #{source.id}: {source.name}"[:4000]
runtime.project_fingerprint = target_fingerprint
runtime.inventory_json = device.project_inventory_json or "{}"
runtime.version = int(runtime.version or 0) + 1
runtime.active = True
runtime.updated_by = user.login
runtime.updated_at = now
session.execute(delete(VmixMappingField).where(VmixMappingField.profile_id == runtime.id))
session.flush()
for index, item in enumerate(rebound):
session.add(VmixMappingField(
profile_id=runtime.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 runtime, report
async def use_mapping_profile_for_user(self, profile_id: int, payload: Any, user: HockeyUser) -> dict[str, Any]:
"""Use any saved Mapping config on the Agent selected for the user's current match."""
requested = str(getattr(payload, "device_id", "") or "").strip()
session_token = str(getattr(payload, "session_token", "") or "").strip()
with self.database.session() as session:
source = session.get(VmixMappingProfile, profile_id)
if source is None or self._runtime_mapping_source_id(source):
raise HTTPException(status_code=404, detail="Mapping-конфиг не найден")
operator = None
if session_token:
operator = session.scalar(select(OperatorSession).where(and_(
OperatorSession.session_token == session_token,
OperatorSession.wfl_user_id == user.id,
OperatorSession.status == "active",
)))
if operator is None:
operator = session.scalar(
select(OperatorSession)
.where(and_(OperatorSession.wfl_user_id == user.id, OperatorSession.status == "active"))
.order_by(desc(OperatorSession.id))
)
if not requested and operator is not None:
requested = str(operator.vmix_device_uuid or "").strip()
if requested:
requested = self.normalise_device_id(requested)
device = session.scalar(select(VmixDevice).where(and_(
VmixDevice.device_uuid == requested,
VmixDevice.wfl_user_id == user.id,
VmixDevice.is_active_for_account.is_(True),
)))
else:
candidates = list(session.scalars(
select(VmixDevice)
.where(and_(VmixDevice.wfl_user_id == user.id, VmixDevice.is_active_for_account.is_(True)))
.order_by(desc(VmixDevice.last_seen_at))
))
device = candidates[0] if len(candidates) == 1 else None
if device is not None:
requested = str(device.device_uuid)
if device is None:
raise HTTPException(status_code=409, detail="Не удалось определить ваш Agent. Выберите Agent для текущей сессии матча.")
if not device.project_fingerprint or not str(device.project_inventory_json or "").strip():
raise HTTPException(status_code=409, detail="Ваш Agent ещё не передал структуру vMix")
if not bool(device.vmix_connected):
raise HTTPException(status_code=409, detail="На вашем Agent сейчас не подключён vMix")
# Make sure this concrete Agent is attached to the current match before sending values.
assignment = await self.assign_current_match(user, device_id=requested)
if assignment is None:
raise HTTPException(status_code=409, detail="Сначала откройте матч в основном интерфейсе")
with self.database.session() as session:
source = session.get(VmixMappingProfile, profile_id)
device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == requested))
if source is None or device is None:
raise HTTPException(status_code=404, detail="Mapping или Agent больше недоступен")
source_payload = self._mapping_profile_payload(session, source, include_inventory=False, include_fields=True)
source_fields = list(source_payload.get("fields") or [])
target_fingerprint = str(device.project_fingerprint or "")
if str(source.project_fingerprint or "") == target_fingerprint:
now = _utcnow()
for other in session.scalars(select(VmixMappingProfile).where(and_(
VmixMappingProfile.project_fingerprint == target_fingerprint,
VmixMappingProfile.active.is_(True),
VmixMappingProfile.id != source.id,
))):
other.active = False
other.updated_by = user.login
other.updated_at = now
source.active = True
source.updated_by = user.login
source.updated_at = now
runtime = source
report = {
"total": len(source_fields), "mapped": len(source_fields), "skipped": 0,
"matched_by": {"key": len(source_fields), "title": 0, "number": 0},
"skipped_items": [], "reused": True,
}
else:
runtime, report = self._upsert_runtime_mapping_for_device(
session, source=source, source_fields=source_fields, device=device, user=user,
)
runtime_id = runtime.id
await self._broadcast_mapping_for_fingerprint(target_fingerprint)
applied = await self.apply_mapping_to_device(requested, reason="mapping_config_selected")
return {
"ok": True,
"device_id": requested,
"profile": {"id": source_payload.get("id"), "name": source_payload.get("name"), "version": source_payload.get("version")},
"runtime_profile_id": runtime_id,
"report": report,
"applied": applied,
}
async def export_mapping_profile(self, profile_id: int) -> dict[str, Any]:
with self.database.session() as session:
profile = session.get(VmixMappingProfile, profile_id)
@@ -2290,7 +2496,11 @@ class VmixAgentHub:
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)))
rows = list(session.scalars(
select(VmixMappingProfile)
.where(~VmixMappingProfile.name.like("__AUTO_MAPPING__%"))
.order_by(desc(VmixMappingProfile.updated_at), VmixMappingProfile.name)
))
items = [self._mapping_profile_payload(session, row, include_inventory=False, include_fields=False) for row in rows]
for item, row in zip(items, rows):
item["field_count"] = len(list(session.scalars(select(VmixMappingField.id).where(VmixMappingField.profile_id == row.id))))
@@ -2426,6 +2636,11 @@ class VmixAgentHub:
if profile is None:
raise HTTPException(status_code=404, detail="Mapping-профиль не найден")
fingerprint = profile.project_fingerprint
runtime_prefix = f"__AUTO_MAPPING__{profile_id}__%"
runtime_profiles = list(session.scalars(select(VmixMappingProfile).where(VmixMappingProfile.name.like(runtime_prefix))))
for runtime in runtime_profiles:
session.execute(delete(VmixMappingField).where(VmixMappingField.profile_id == runtime.id))
session.delete(runtime)
for field in list(session.scalars(select(VmixMappingField).where(VmixMappingField.profile_id == profile_id))):
session.delete(field)
session.delete(profile)
@@ -2606,6 +2821,11 @@ class MappingFieldsReplacePayload(BaseModel):
fields: list[MappingFieldPayload] = Field(default_factory=list, max_length=2000)
class MappingProfileUsePayload(BaseModel):
device_id: str = Field(default="", max_length=128)
session_token: str = Field(default="", max_length=128)
class MappingProfileCopyPayload(BaseModel):
device_id: str = Field(min_length=6, max_length=128)
name: str = Field(default="", max_length=200)
@@ -2840,6 +3060,10 @@ 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.post("/api/hockey/admin/vmix-mapping/profiles/{profile_id}/use", dependencies=admin)
async def mapping_profile_use(profile_id: int, payload: MappingProfileUsePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
return await hub.use_mapping_profile_for_user(profile_id, 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)

View File

@@ -34,6 +34,8 @@ from .models import (
TournamentStandingRow,
TournamentStatisticResource,
TournamentStatisticRow,
Team,
TeamLeague,
TeamSeasonStatistic,
UserPreference,
VmixAssignment,
@@ -161,6 +163,8 @@ class HockeyDatabase:
repaired = migrated + self._repair_game_table()
for model in (
OperatorSession,
Team,
TeamLeague,
Country,
PenaltyType,
Player,

View File

@@ -254,6 +254,7 @@ class Team(Base):
short_name_en: Mapped[str] = mapped_column(String(255), nullable=False, default="")
city_ru: Mapped[str] = mapped_column(String(255), nullable=False, default="")
city_en: Mapped[str] = mapped_column(String(255), nullable=False, default="")
color_hex: Mapped[str] = mapped_column(String(9), nullable=False, default="")
logo_url: Mapped[str] = mapped_column(Text, nullable=False, default="")
raw_payload: Mapped[str] = mapped_column(Text, nullable=False, default="")
synced_at: Mapped[datetime] = mapped_column(

View File

@@ -141,6 +141,7 @@ class TeamDirectoryPayload(BaseModel):
short_name_en: str = Field(default="", max_length=255)
city_ru: str = Field(default="", max_length=255)
city_en: str = Field(default="", max_length=255)
color_hex: str = Field(default="", max_length=9)
logo_url: str = Field(default="", max_length=2000)
active: bool = True

View File

@@ -88,6 +88,18 @@ from .tournament_statistics_parser import parse_tournament_statistics_xml
from .navigation_labels import parse_navigation_labels_xml
def normalise_team_color_hex(value: Any) -> str:
"""Return an uppercase #RRGGBB value or an empty string for unset colours."""
raw_color = str(value or "").strip().upper()
if not raw_color:
return ""
if not raw_color.startswith("#"):
raw_color = f"#{raw_color}"
if not re.fullmatch(r"#[0-9A-F]{6}", raw_color):
raise ValueError("Цвет команды должен быть в формате #RRGGBB")
return raw_color
DEFAULT_PENALTY_TYPES: tuple[tuple[str, str, str, str], ...] = (
("TRIP", "Подножка", "Tripping", "2"),
("HOOK", "Задержка клюшкой", "Hooking", "2"),
@@ -596,6 +608,7 @@ class HockeyDataService:
"short_name_en": team.short_name_en,
"city_ru": team.city_ru,
"city_en": team.city_en,
"color_hex": team.color_hex,
"logo_url": team.logo_url,
"source": membership.source,
"active": bool(membership.active),
@@ -742,6 +755,10 @@ class HockeyDataService:
):
if field in payload and payload[field] is not None:
setattr(team, field, str(payload[field]).strip())
if "color_hex" in payload and payload["color_hex"] is not None:
team.color_hex = normalise_team_color_hex(payload["color_hex"])
if not (team.name_ru or team.name_en):
raise ValueError("Укажите название команды на русском или английском")

View File

@@ -1599,3 +1599,109 @@
.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%}}
/* Build 73: editable team HEX colour */
.hockey-team-color-field {
display: grid;
gap: 4px;
}
.hockey-team-color-field small {
color: #627990;
font-size: 9px;
}
.hockey-team-color-control {
display: grid;
grid-template-columns: 42px minmax(0, 1fr) 34px;
gap: 7px;
align-items: center;
}
.hockey-team-color-picker-wrap,
.hockey-team-color-clear {
position: relative;
width: 100%;
min-height: 35px;
padding: 0;
border: 1px solid #304861;
border-radius: 8px;
background: #0a1624;
cursor: pointer;
}
.hockey-team-color-picker-wrap:hover,
.hockey-team-color-clear:hover {
border-color: #48dfbd;
}
.hockey-team-color-picker-wrap > i {
display: block;
width: 24px;
height: 24px;
margin: auto;
border: 2px solid rgba(255,255,255,.82);
border-radius: 7px;
background: var(--team-color, #fff);
box-shadow: 0 0 0 1px rgba(0,0,0,.38), inset 0 0 0 1px rgba(0,0,0,.16);
}
.hockey-team-color-picker-wrap input[type="color"] {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
min-height: 0;
padding: 0;
opacity: 0;
cursor: pointer;
}
.hockey-team-color-control input[data-team-color-text] {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-weight: 800;
letter-spacing: .5px;
text-transform: uppercase;
}
.hockey-team-color-clear {
color: #879bb1;
font-size: 18px;
line-height: 1;
}
.hockey-team-color-cell {
display: inline-flex;
align-items: center;
gap: 7px;
}
.hockey-team-color-cell i {
width: 18px;
height: 18px;
flex: 0 0 18px;
border: 1px solid rgba(255,255,255,.5);
border-radius: 5px;
background: var(--team-color, transparent);
box-shadow: 0 0 0 1px rgba(0,0,0,.28);
}
.hockey-team-color-cell code {
color: #b9c9da;
font-size: 9px;
}
/* Build 74: reusable Mapping configs + one-click apply to current Agent. */
.hockey-map-config-hint{font-size:11px;line-height:1.45;color:#8ea0b6;background:#0d1520;border:1px solid #2b394c;border-radius:10px;padding:9px 10px}
.hockey-mapping-profile-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px;align-items:stretch;border-radius:11px}
.hockey-mapping-profile-row.is-active{box-shadow:0 0 0 1px rgba(56,189,248,.18)}
.hockey-mapping-profile-row .hockey-mapping-profile{min-width:0;width:100%}
.hockey-map-use-profile{min-width:106px;border:1px solid #2f5870;background:#102433;color:#9bdcff;border-radius:10px;padding:7px 9px;font-size:11px;font-weight:700;cursor:pointer;white-space:normal;line-height:1.25}
.hockey-map-use-profile:hover{border-color:#38bdf8;background:#123047;color:#d9f4ff}
.hockey-map-use-profile.is-used{border-color:#276749;background:#102b20;color:#8ff0b5}
.hockey-mapping-create-wrap{border:1px solid #334155;background:#101722;border-radius:12px;overflow:hidden}
.hockey-mapping-create-wrap>summary{cursor:pointer;padding:10px 12px;color:#b9c7d8;font-size:12px;font-weight:700;list-style:none}
.hockey-mapping-create-wrap>summary::-webkit-details-marker{display:none}
.hockey-mapping-create-wrap[open]>summary{border-bottom:1px solid #2e3b4e}
.hockey-mapping-create-wrap .hockey-mapping-create{border:0;border-radius:0;background:transparent}
@media(max-width:520px){.hockey-mapping-profile-row{grid-template-columns:1fr}.hockey-map-use-profile{min-height:34px}}

View File

@@ -63,6 +63,18 @@
.replaceAll('"', """)
.replaceAll("'", "'");
const normalizeTeamColorHex = (value) => {
let color = String(value ?? "").trim().toUpperCase();
if (!color) return "";
if (!color.startsWith("#")) color = `#${color}`;
return /^#[0-9A-F]{6}$/.test(color) ? color : "";
};
const teamColorPreview = (value) => {
const color = normalizeTeamColorHex(value);
return color || "#FFFFFF";
};
const countryFlagMarkup = (item) => {
const code = String(item?.iso2 || item?.country_code || "").trim().toLowerCase().replace(/[^a-z]/g, "").slice(0, 2);
const url = String(item?.flag_url || (code.length === 2 ? `/hockey-assets/flags/${code}.svg` : ""));
@@ -301,11 +313,12 @@
<td><strong>${escapeHtml(item.name_ru || "—")}</strong><small>${escapeHtml(item.short_name_ru || "")}</small></td>
<td><strong>${escapeHtml(item.name_en || "—")}</strong><small>${escapeHtml(item.short_name_en || "")}</small></td>
<td>${escapeHtml(item.city_ru || item.city_en || "—")}</td>
<td>${item.color_hex ? `<span class="hockey-team-color-cell"><i style="--team-color:${escapeHtml(item.color_hex)}"></i><code>${escapeHtml(item.color_hex)}</code></span>` : "—"}</td>
<td><span class="hockey-directory-source">${escapeHtml(item.source === "manual" ? "изменено" : "Stat2TV")}</span></td>
<td><button type="button" class="hockey-directory-edit" data-edit-team="${item.id}">Изменить</button></td>
</tr>
`).join("")
: `<tr><td colspan="6" class="hockey-directory-empty">Для этой лиги команды ещё не загружены. Нажмите «Обновить из API».</td></tr>`;
: `<tr><td colspan="7" class="hockey-directory-empty">Для этой лиги команды ещё не загружены. Нажмите «Обновить из API».</td></tr>`;
const content = `
<div class="hockey-directory-toolbar">
@@ -320,7 +333,7 @@
<div class="hockey-directory-grid">
<div class="hockey-directory-table-wrap">
<table>
<thead><tr><th>ID API</th><th>Русский</th><th>English</th><th>Город</th><th>Источник</th><th></th></tr></thead>
<thead><tr><th>ID API</th><th>Русский</th><th>English</th><th>Город</th><th>Цвет</th><th>Источник</th><th></th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>
@@ -339,6 +352,20 @@
<label><span>City (English)</span><input name="city_en" maxlength="255" value="${escapeHtml(editing?.city_en || "")}"></label>
</div>
<label><span>Логотип (URL)</span><input name="logo_url" maxlength="2000" value="${escapeHtml(editing?.logo_url || "")}"></label>
<div class="hockey-team-color-field">
<label>
<span>Цвет команды</span>
<div class="hockey-team-color-control">
<button type="button" class="hockey-team-color-picker-wrap" data-team-color-open title="Выбрать цвет">
<i data-team-color-preview style="--team-color:${escapeHtml(teamColorPreview(editing?.color_hex))}"></i>
<input type="color" data-team-color-picker value="${escapeHtml(teamColorPreview(editing?.color_hex))}" tabindex="-1" aria-label="Выбрать цвет команды">
</button>
<input name="color_hex" data-team-color-text maxlength="7" placeholder="#282E66" value="${escapeHtml(editing?.color_hex || "")}" autocomplete="off" spellcheck="false">
<button type="button" class="hockey-team-color-clear" data-team-color-clear title="Очистить цвет">×</button>
</div>
<small>HEX, например #282E66</small>
</label>
</div>
<label class="hockey-directory-check"><input type="checkbox" name="active" ${editing?.active === false ? "" : "checked"}><span>Активна</span></label>
<div class="hockey-directory-form-actions">
${editing ? `<button type="button" data-cancel-team>Отмена</button>` : ""}
@@ -2127,11 +2154,19 @@
<span>${item.mapping ? `Mapping: <b>${escapeHtml(item.mapping.name)}</b> · v${Number(item.mapping.version || 1)}` : (item.project_fingerprint ? "Mapping не назначен" : "Ожидание структуры vMix")}</span>
</article>`).join("") : `<div class="hockey-directory-empty">Agent пока не передал структуру ни одного vMix.</div>`;
const profileButtons = profiles.length ? profiles.map((item) => `
<button type="button" class="hockey-mapping-profile ${profile?.id === item.id ? "is-active" : ""}" data-mapping-profile="${item.id}">
<strong>${escapeHtml(item.name)}</strong>
<small>v${Number(item.version || 1)} · ${Number(item.field_count || 0)} связей</small>
</button>`).join("") : `<div class="hockey-directory-empty">Mapping-профилей ещё нет.</div>`;
const selectedDeviceId = String(localStorage.getItem("hockey.vmix.selected_device") || "").trim();
const selectedDevice = devices.find((item) => String(item.device_id || "") === selectedDeviceId) || null;
const profileButtons = profiles.length ? profiles.map((item) => {
const isUsed = Boolean(selectedDevice?.mapping && Number(selectedDevice.mapping.source_profile_id || selectedDevice.mapping.id || 0) === Number(item.id));
return `
<div class="hockey-mapping-profile-row ${profile?.id === item.id ? "is-active" : ""}">
<button type="button" class="hockey-mapping-profile ${profile?.id === item.id ? "is-active" : ""}" data-mapping-profile="${item.id}">
<strong>${escapeHtml(item.name)}</strong>
<small>v${Number(item.version || 1)} · ${Number(item.field_count || 0)} связей${item.created_by ? ` · ${escapeHtml(item.created_by)}` : ""}</small>
</button>
<button type="button" class="hockey-map-use-profile ${isUsed ? "is-used" : ""}" data-map-use-profile="${item.id}" ${isUsed ? 'title="Этот конфиг уже выбран на вашем Agent"' : ""}>${isUsed ? "✓ Используется" : "Применить к моему Agent"}</button>
</div>`;
}).join("") : `<div class="hockey-directory-empty">Mapping-конфигов ещё нет.</div>`;
const loadErrors = Object.entries(state.mappingLoadErrors || {});
const mappingDiagnostics = loadErrors.length ? `<div class="hockey-map-load-errors"><strong>Часть Mapping API недоступна</strong>${loadErrors.map(([key, value]) => `<span><code>${escapeHtml(key)}</code>${escapeHtml(value)}</span>`).join("")}</div>` : "";
@@ -2173,14 +2208,9 @@
<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>
<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>
<div><button type="button" class="is-accent" data-map-save>Сохранить конфиг</button></div>
</div>`;
}
@@ -2189,24 +2219,18 @@
<aside class="hockey-mapping-sidebar">
<h3>vMix проекты</h3>
<div class="hockey-mapping-devices">${deviceRows}</div>
<form class="hockey-mapping-create" data-mapping-create>
<h3>Новый Mapping</h3>
<label>Agent / текущий vMix<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" required placeholder="KHL_MAIN_2026"></label>
<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>
<h3>Конфиги Mapping</h3>
<div class="hockey-map-config-hint">Выберите любой готовый конфиг — свой или чужой — и нажмите «Применить к моему Agent». Он будет использован для текущего открытого матча.</div>
<div class="hockey-mapping-profiles">${profileButtons}</div>
<details class="hockey-mapping-create-wrap">
<summary>+ Создать новый конфиг из vMix</summary>
<form class="hockey-mapping-create" data-mapping-create>
<label>Agent / текущий vMix<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" required placeholder="KHL_MAIN_2026"></label>
<label>Описание<input name="description" placeholder="Основной графический пакет"></label>
<button type="submit" class="is-accent" ${usableDevices.length ? "" : "disabled"}>Создать конфиг</button>
</form>
</details>
</aside>
<section class="hockey-mapping-workspace">${statusMarkup()}${mappingDiagnostics}${editor}</section>
</div>`);
@@ -2406,6 +2430,32 @@
modal.querySelector("[data-mapping-name]")?.addEventListener("input", (event) => { if (state.mappingActiveProfile) state.mappingActiveProfile.name = event.currentTarget.value; });
modal.querySelector("[data-mapping-description]")?.addEventListener("input", (event) => { if (state.mappingActiveProfile) state.mappingActiveProfile.description = event.currentTarget.value; });
modal.querySelector("[data-mapping-active]")?.addEventListener("change", (event) => { if (state.mappingActiveProfile) state.mappingActiveProfile.active = Boolean(event.currentTarget.checked); });
modal.querySelectorAll("[data-map-use-profile]").forEach((button) => button.addEventListener("click", async (event) => {
event.stopPropagation();
const profileId = Number(button.dataset.mapUseProfile || 0);
if (!profileId) return;
const selected = (state.mappingProfiles?.profiles || []).find((item) => Number(item.id) === profileId);
const deviceId = String(localStorage.getItem("hockey.vmix.selected_device") || "").trim();
const sessionToken = String(selectedContext().token || "").trim();
button.disabled = true;
button.textContent = "Применяю…";
try {
const result = await request(`/api/hockey/admin/vmix-mapping/profiles/${profileId}/use`, {
method: "POST",
body: JSON.stringify({ device_id: deviceId, session_token: sessionToken }),
});
await loadMapping(profileId);
const applied = result.applied || {};
const report = result.report || {};
const suffix = Number(report.skipped || 0) ? ` · не сопоставлено ${Number(report.skipped || 0)}` : "";
const send = applied.ok === false ? ` · ${escapeHtml(applied.reason || "не удалось отправить данные")}` : ` · отправлено ${Number(applied.applied || 0)} из ${Number(applied.total || 0)}`;
setStatus(`✓ Конфиг «${selected?.name || result.profile?.name || profileId}» выбран для моего Agent${send}${suffix}`, Number(report.skipped || 0) > 0 || applied.ok === false);
} catch (error) {
setStatus(error.message || "Не удалось применить Mapping-конфиг", true);
}
renderMapping();
}));
modal.querySelectorAll("[data-mapping-profile]").forEach((button) => button.addEventListener("click", async () => {
try {
state.mappingActiveProfile = await request(`/api/hockey/admin/vmix-mapping/profiles/${button.dataset.mappingProfile}`);
@@ -2756,7 +2806,7 @@
if (currentScroll) state.mappingVmixScrollTop = currentScroll.scrollTop;
const id = state.mappingActiveProfile.id;
try {
await request(`/api/hockey/admin/vmix-mapping/profiles/${id}`, { method: "PUT", body: JSON.stringify({ name: modal.querySelector("[data-mapping-name]")?.value || state.mappingActiveProfile.name, description: modal.querySelector("[data-mapping-description]")?.value || "", active: Boolean(modal.querySelector("[data-mapping-active]")?.checked) }) });
await request(`/api/hockey/admin/vmix-mapping/profiles/${id}`, { method: "PUT", body: JSON.stringify({ name: modal.querySelector("[data-mapping-name]")?.value || state.mappingActiveProfile.name, description: modal.querySelector("[data-mapping-description]")?.value || "", active: modal.querySelector("[data-mapping-active]") ? Boolean(modal.querySelector("[data-mapping-active]")?.checked) : state.mappingActiveProfile.active !== false }) });
const result = await request(`/api/hockey/admin/vmix-mapping/profiles/${id}/fields`, { method: "PUT", body: JSON.stringify({ fields: collectMappingFields() }) });
await loadMapping(id); setStatus(`Mapping сохранён · версия ${result.version}.`);
} catch (error) { setStatus(error.message, true); }
@@ -2885,10 +2935,57 @@
setStatus("");
renderTeams();
});
const colorText = modal.querySelector("[data-team-color-text]");
const colorPicker = modal.querySelector("[data-team-color-picker]");
const colorPreview = modal.querySelector("[data-team-color-preview]");
const syncTeamColorUi = (rawValue, { commit = false } = {}) => {
const normalized = normalizeTeamColorHex(rawValue);
if (normalized) {
if (colorText && (commit || colorText.value !== rawValue)) colorText.value = normalized;
if (colorPicker) colorPicker.value = normalized;
if (colorPreview) colorPreview.style.setProperty("--team-color", normalized);
return normalized;
}
if (commit && colorText && String(rawValue || "").trim()) {
setStatus("Цвет команды должен быть в формате #RRGGBB", true);
}
if (!String(rawValue || "").trim()) {
if (colorText && commit) colorText.value = "";
if (colorPicker) colorPicker.value = "#FFFFFF";
if (colorPreview) colorPreview.style.setProperty("--team-color", "#FFFFFF");
}
return "";
};
modal.querySelector("[data-team-color-open]")?.addEventListener("click", () => colorPicker?.click());
colorPicker?.addEventListener("input", () => {
if (colorText) colorText.value = String(colorPicker.value || "").toUpperCase();
syncTeamColorUi(colorPicker.value);
});
colorText?.addEventListener("input", () => {
const raw = String(colorText.value || "");
const compact = raw.replace(/\s+/g, "").toUpperCase();
if (raw !== compact) colorText.value = compact;
const normalized = normalizeTeamColorHex(compact);
if (normalized) syncTeamColorUi(normalized);
});
colorText?.addEventListener("blur", () => syncTeamColorUi(colorText.value, { commit: true }));
modal.querySelector("[data-team-color-clear]")?.addEventListener("click", () => {
if (colorText) colorText.value = "";
syncTeamColorUi("", { commit: true });
colorText?.focus();
});
modal.querySelector("[data-team-form]")?.addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
const values = new FormData(form);
const rawTeamColor = String(values.get("color_hex") || "").trim();
const normalizedTeamColor = normalizeTeamColorHex(rawTeamColor);
if (rawTeamColor && !normalizedTeamColor) {
setStatus("Цвет команды должен быть в формате #RRGGBB", true);
colorText?.focus();
return;
}
const payload = {
external_id: values.get("external_id") || state.editingTeam?.external_id || "",
league_key: values.get("league_key") || "",
@@ -2899,6 +2996,7 @@
short_name_en: values.get("short_name_en") || "",
city_ru: values.get("city_ru") || "",
city_en: values.get("city_en") || "",
color_hex: normalizedTeamColor,
logo_url: values.get("logo_url") || "",
active: values.has("active"),
};