маппинг

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

@@ -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"),
};