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