3212 lines
206 KiB
JavaScript
3212 lines
206 KiB
JavaScript
(() => {
|
||
"use strict";
|
||
|
||
const state = {
|
||
language: localStorage.getItem("hockey.language") || "ru",
|
||
isAdmin: false,
|
||
open: false,
|
||
section: "teams",
|
||
teams: null,
|
||
penalties: null,
|
||
timerRules: null,
|
||
timerRulesVmixInventory: null,
|
||
vmixSettings: null,
|
||
vmixChannel: null,
|
||
vmixActiveKey: "",
|
||
vmixPanel: "configs",
|
||
vmixSourceFields: null,
|
||
vmixImportPreview: null,
|
||
mappingDevices: null,
|
||
mappingProfiles: null,
|
||
mappingActiveProfile: null,
|
||
mappingCreateDeviceId: "",
|
||
mappingSelectedInputKey: "",
|
||
mappingTargetField: "",
|
||
mappingSelectedSourceKey: "",
|
||
mappingDataSearch: "",
|
||
mappingInputSearch: "",
|
||
mappingFieldFilter: "text",
|
||
mappingHideLinked: false,
|
||
mappingRuleField: "",
|
||
mappingVmixScrollTop: 0,
|
||
mappingSelectedTableSource: "",
|
||
mappingSelectedTableRow: 1,
|
||
mappingSelectedTableColumn: "",
|
||
mappingTableRowSearch: "",
|
||
mappingSqlCellOpen: false,
|
||
mappingSqlTableOpen: false,
|
||
mappingOpenDataGroups: {},
|
||
mappingTestDeviceId: "",
|
||
mappingWorkspaceTab: "links",
|
||
mappingCatalog: null,
|
||
mappingContextVariables: null,
|
||
mappingSqlSources: null,
|
||
mappingSelectedSqlSourceId: null,
|
||
mappingSqlPreview: null,
|
||
mappingSqlDraft: null,
|
||
mappingLoadErrors: {},
|
||
mappingContextRefreshTimer: null,
|
||
countries: null,
|
||
players: null,
|
||
referees: null,
|
||
coaches: null,
|
||
teamLeague: "",
|
||
directorySearch: "",
|
||
editingTeam: null,
|
||
editingPenalty: null,
|
||
editingRecord: null,
|
||
status: "",
|
||
statusError: false,
|
||
modal: null,
|
||
};
|
||
|
||
const escapeHtml = (value) => String(value ?? "")
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.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` : ""));
|
||
if (url) return `<img src="${escapeHtml(url)}" alt="${escapeHtml(code.toUpperCase())}" title="${escapeHtml(item?.country_name || item?.name || code.toUpperCase())}" loading="lazy" onerror="this.hidden=true;this.nextElementSibling.hidden=false"><span class="hockey-directory-flag-fallback" hidden>${escapeHtml(item?.flag || "")}</span>`;
|
||
return escapeHtml(item?.flag || "");
|
||
};
|
||
|
||
async function request(url, options = {}) {
|
||
const response = await fetch(url, {
|
||
credentials: "same-origin",
|
||
...options,
|
||
headers: {
|
||
...(options.body ? { "Content-Type": "application/json" } : {}),
|
||
...(options.headers || {}),
|
||
},
|
||
});
|
||
const data = await response.json().catch(() => ({}));
|
||
if (!response.ok) {
|
||
throw new Error(data.detail || `Ошибка запроса: ${response.status}`);
|
||
}
|
||
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;
|
||
const tournament = data.hockey?.selected_tournament || null;
|
||
const operatorSession = data.hockey?.operator_session || data.hockey?.session || null;
|
||
return {
|
||
game,
|
||
tournament,
|
||
tournamentLevel: String(tournament?.level || game?.league_key || game?.level || "").toLowerCase(),
|
||
tournamentId: String(
|
||
game?.tournament_external_id
|
||
|| localStorage.getItem("hockey.selectedTournamentId")
|
||
|| ""
|
||
),
|
||
token: String(
|
||
operatorSession?.channel_key
|
||
|| operatorSession?.token
|
||
|| state.vmixChannel?.channel_key
|
||
|| state.vmixChannel?.token
|
||
|| localStorage.getItem("hockey.operatorSessionToken")
|
||
|| ""
|
||
),
|
||
language: state.language === "en" ? "en" : "ru",
|
||
};
|
||
}
|
||
|
||
function isKhlTournamentSelected() {
|
||
const context = selectedContext();
|
||
return context.tournamentLevel === "khl";
|
||
}
|
||
|
||
function patchRuntimePenalties(items) {
|
||
const activeItems = (items || [])
|
||
.filter((item) => item.active !== false)
|
||
.map((item) => ({
|
||
...item,
|
||
name: state.language === "en"
|
||
? (item.name_en || item.name_ru || item.code)
|
||
: (item.name_ru || item.name_en || item.code),
|
||
}));
|
||
window.UIBuilderRuntime?.patchData?.({
|
||
hockey: {
|
||
penalty_directory: activeItems,
|
||
},
|
||
});
|
||
}
|
||
|
||
async function loadPenalties(includeInactive = false) {
|
||
const params = new URLSearchParams({ language: state.language });
|
||
if (includeInactive) params.set("include_inactive", "true");
|
||
const data = await request(`/api/hockey/directories/penalties?${params}`);
|
||
state.penalties = data;
|
||
patchRuntimePenalties(data.items);
|
||
return data;
|
||
}
|
||
|
||
async function loadTimerRules() {
|
||
const deviceId = String(localStorage.getItem("hockey.vmix.selected_device") || "").trim();
|
||
const [settings, inventory] = await Promise.all([
|
||
request("/api/hockey/settings"),
|
||
request(`/api/hockey/agents/vmix-inventory${deviceId ? `?device_id=${encodeURIComponent(deviceId)}` : ""}`).catch(() => null),
|
||
]);
|
||
state.timerRules = settings;
|
||
state.timerRulesVmixInventory = inventory;
|
||
return state.timerRules;
|
||
}
|
||
|
||
async function loadTeams() {
|
||
const context = selectedContext();
|
||
const params = new URLSearchParams({
|
||
language: state.language,
|
||
include_inactive: "true",
|
||
});
|
||
if (context.tournamentId) {
|
||
params.set("tournament_external_id", context.tournamentId);
|
||
} else if (state.teamLeague) {
|
||
params.set("league_key", state.teamLeague);
|
||
}
|
||
state.teams = await request(`/api/hockey/directories/teams?${params}`);
|
||
if (context.tournamentId) {
|
||
state.teamLeague = state.teams.selected_league || "";
|
||
}
|
||
return state.teams;
|
||
}
|
||
|
||
function setStatus(message, isError = false) {
|
||
state.status = String(message || "");
|
||
state.statusError = isError;
|
||
}
|
||
|
||
function statusMarkup() {
|
||
if (!state.status) return "";
|
||
return `<div class="hockey-directory-status ${state.statusError ? "is-error" : ""}">${escapeHtml(state.status)}</div>`;
|
||
}
|
||
|
||
function isMappingSection(section = state.section) {
|
||
return ["mapping", "mapping_context", "mapping_sql"].includes(String(section || ""));
|
||
}
|
||
|
||
function mappingTabFromSection(section = state.section) {
|
||
if (section === "mapping_context") return "context";
|
||
if (section === "mapping_sql") return "sql";
|
||
return "links";
|
||
}
|
||
|
||
function ensureModal() {
|
||
if (state.modal) return state.modal;
|
||
const modal = document.createElement("div");
|
||
modal.className = "hockey-directory-backdrop hidden";
|
||
modal.addEventListener("mousedown", (event) => {
|
||
if (event.target === modal) closeModal();
|
||
});
|
||
document.body.appendChild(modal);
|
||
state.modal = modal;
|
||
return modal;
|
||
}
|
||
|
||
function closeModal() {
|
||
state.open = false;
|
||
state.editingTeam = null;
|
||
state.editingPenalty = null;
|
||
state.editingRecord = null;
|
||
state.status = "";
|
||
ensureModal().classList.add("hidden");
|
||
document.body.classList.remove("hockey-admin-settings-open");
|
||
window.dispatchEvent(new CustomEvent("hockey:admin-settings-visibility", { detail: { open: false } }));
|
||
}
|
||
|
||
function shellMarkup(content) {
|
||
return `
|
||
<section class="hockey-directory-modal ${state.section === "khl_site" ? "is-khl-site" : ""} ${isMappingSection() ? "is-mapping" : ""}" role="dialog" aria-modal="true" aria-label="Справочники базы данных">
|
||
<header>
|
||
<div>
|
||
<span>НАСТРОЙКИ</span>
|
||
<strong>Общие настройки хоккея</strong>
|
||
</div>
|
||
<button type="button" class="hockey-directory-close" data-directory-close aria-label="Закрыть">×</button>
|
||
</header>
|
||
<nav>
|
||
<button type="button" data-directory-section="teams" class="${state.section === "teams" ? "is-active" : ""}">Команды</button>
|
||
<button type="button" data-directory-section="players" class="${state.section === "players" ? "is-active" : ""}">Игроки</button>
|
||
<button type="button" data-directory-section="coaches" class="${state.section === "coaches" ? "is-active" : ""}">Тренеры</button>
|
||
<button type="button" data-directory-section="referees" class="${state.section === "referees" ? "is-active" : ""}">Судьи</button>
|
||
<button type="button" data-directory-section="countries" class="${state.section === "countries" ? "is-active" : ""}">Страны</button>
|
||
<button type="button" data-directory-section="timer_rules" class="${state.section === "timer_rules" ? "is-active" : ""}">Таймеры и составы</button>
|
||
<button type="button" data-directory-section="penalties" class="${state.section === "penalties" ? "is-active" : ""}">Удаления</button>
|
||
<button type="button" data-directory-section="vmix" class="${state.section === "vmix" ? "is-active" : ""}">vMix JSON</button>
|
||
<button type="button" data-directory-section="mapping" class="${state.section === "mapping" ? "is-active" : ""}">Mapping</button>
|
||
<button type="button" data-directory-section="mapping_context" class="${state.section === "mapping_context" ? "is-active" : ""}">Переменные</button>
|
||
<button type="button" data-directory-section="mapping_sql" class="${state.section === "mapping_sql" ? "is-active" : ""}">SQL источники</button>
|
||
${isKhlTournamentSelected() ? `<button type="button" data-directory-section="khl_site" class="${state.section === "khl_site" ? "is-active" : ""}">КХЛ сайт</button>` : ""}
|
||
</nav>
|
||
<div class="hockey-directory-content">${content}</div>
|
||
</section>
|
||
`;
|
||
}
|
||
|
||
function leagueOptions(selected) {
|
||
const leagues = state.teams?.leagues || [];
|
||
return leagues.map((item) => `
|
||
<option value="${escapeHtml(item.value)}" ${item.value === selected ? "selected" : ""}>${escapeHtml(item.label)} (${escapeHtml(item.value.toUpperCase())})</option>
|
||
`).join("");
|
||
}
|
||
|
||
function renderTeams() {
|
||
const data = state.teams || { items: [], leagues: [] };
|
||
const context = selectedContext();
|
||
const lockedLeague = context.tournamentId ? data.selected_league : "";
|
||
const editing = state.editingTeam;
|
||
const formLeague = editing?.league_key || lockedLeague || state.teamLeague || "";
|
||
const formTournament = editing?.tournament_external_id || context.tournamentId || "";
|
||
const leagueControl = lockedLeague
|
||
? `<div class="hockey-directory-context"><span>Лига выбранного матча</span><strong>${escapeHtml(data.selected_league_label || lockedLeague.toUpperCase())}</strong></div>`
|
||
: `
|
||
<label class="hockey-directory-filter">
|
||
<span>Показать лигу</span>
|
||
<select data-team-league-filter>
|
||
<option value="">Все лиги</option>
|
||
${leagueOptions(state.teamLeague)}
|
||
</select>
|
||
</label>
|
||
`;
|
||
|
||
const rows = data.items.length
|
||
? data.items.map((item) => `
|
||
<tr class="${item.active ? "" : "is-inactive"}">
|
||
<td><code>${escapeHtml(item.external_id)}</code></td>
|
||
<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="7" class="hockey-directory-empty">Для этой лиги команды ещё не загружены. Нажмите «Обновить из API».</td></tr>`;
|
||
|
||
const content = `
|
||
<div class="hockey-directory-toolbar">
|
||
${leagueControl}
|
||
<div class="hockey-directory-toolbar-actions">
|
||
<button type="button" data-sync-teams>Обновить из API</button>
|
||
<button type="button" class="is-accent" data-new-team>Добавить команду</button>
|
||
</div>
|
||
</div>
|
||
<p class="hockey-directory-note">Команды формируются из матчей Stat2TV и разделяются по лигам. При выбранном матче показывается только его лига.</p>
|
||
${statusMarkup()}
|
||
<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><th></th></tr></thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>
|
||
</div>
|
||
<form class="hockey-directory-form" data-team-form>
|
||
<h3>${editing ? "Редактирование команды" : "Новая команда"}</h3>
|
||
<label><span>ID команды из API</span><input name="external_id" maxlength="64" value="${escapeHtml(editing?.external_id || "")}" ${editing ? "readonly" : "required"}></label>
|
||
<label><span>Лига</span><input name="league_key" list="hockey-league-list" maxlength="32" value="${escapeHtml(formLeague)}" ${lockedLeague ? "readonly" : "required"}></label>
|
||
<datalist id="hockey-league-list">${leagueOptions(formLeague)}</datalist>
|
||
<input type="hidden" name="tournament_external_id" value="${escapeHtml(formTournament)}">
|
||
<div class="hockey-directory-form-columns">
|
||
<label><span>Название (русский)</span><input name="name_ru" maxlength="500" value="${escapeHtml(editing?.name_ru || "")}"></label>
|
||
<label><span>Name (English)</span><input name="name_en" maxlength="500" value="${escapeHtml(editing?.name_en || "")}"></label>
|
||
<label><span>Короткое (русский)</span><input name="short_name_ru" maxlength="255" value="${escapeHtml(editing?.short_name_ru || "")}"></label>
|
||
<label><span>Short name (English)</span><input name="short_name_en" maxlength="255" value="${escapeHtml(editing?.short_name_en || "")}"></label>
|
||
<label><span>Город (русский)</span><input name="city_ru" maxlength="255" value="${escapeHtml(editing?.city_ru || "")}"></label>
|
||
<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>` : ""}
|
||
<button type="submit" class="is-accent">${editing ? "Сохранить" : "Добавить"}</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
`;
|
||
const modal = ensureModal();
|
||
modal.innerHTML = shellMarkup(content);
|
||
bindShell();
|
||
bindTeams();
|
||
}
|
||
|
||
function renderPenalties() {
|
||
const editing = state.editingPenalty;
|
||
const items = state.penalties?.items || [];
|
||
const rows = items.length
|
||
? items.map((item) => `
|
||
<tr class="${item.active ? "" : "is-inactive"}">
|
||
<td><code>${escapeHtml(item.code)}</code></td>
|
||
<td>${escapeHtml(item.name_ru)}</td>
|
||
<td>${escapeHtml(item.name_en)}</td>
|
||
<td><strong>${escapeHtml(item.default_preset)}</strong></td>
|
||
<td>${item.team_penalty ? "Да" : "Нет"}</td>
|
||
<td>${item.active ? "Да" : "Нет"}</td>
|
||
<td><button type="button" class="hockey-directory-edit" data-edit-penalty="${item.id}">Изменить</button></td>
|
||
</tr>
|
||
`).join("")
|
||
: `<tr><td colspan="7" class="hockey-directory-empty">Справочник удалений пуст.</td></tr>`;
|
||
|
||
const content = `
|
||
<div class="hockey-directory-toolbar">
|
||
<div class="hockey-directory-context"><span>Общий справочник</span><strong>Для всех видов хоккея</strong></div>
|
||
<button type="button" class="is-accent" data-new-penalty>Добавить удаление</button>
|
||
</div>
|
||
<p class="hockey-directory-note">Активные записи автоматически появляются в списке нарушений основного меню.</p>
|
||
${statusMarkup()}
|
||
<div class="hockey-directory-grid">
|
||
<div class="hockey-directory-table-wrap">
|
||
<table>
|
||
<thead><tr><th>Код</th><th>Русский</th><th>English</th><th>Штраф</th><th>Командное</th><th>Активно</th><th></th></tr></thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>
|
||
</div>
|
||
<form class="hockey-directory-form" data-penalty-form>
|
||
<h3>${editing ? "Редактирование удаления" : "Новое удаление"}</h3>
|
||
<label><span>Код</span><input name="code" maxlength="64" required value="${escapeHtml(editing?.code || "")}"></label>
|
||
<label><span>Название на русском</span><input name="name_ru" maxlength="500" required value="${escapeHtml(editing?.name_ru || "")}"></label>
|
||
<label><span>Name in English</span><input name="name_en" maxlength="500" required value="${escapeHtml(editing?.name_en || "")}"></label>
|
||
<div class="hockey-directory-form-columns">
|
||
<label><span>Штраф по умолчанию</span><select name="default_preset">
|
||
${["2", "2+2", "4", "5", "5+20", "10"].map((value) => `<option value="${value}" ${(editing?.default_preset || "2") === value ? "selected" : ""}>${value}</option>`).join("")}
|
||
</select></label>
|
||
<label><span>Порядок</span><input name="sort_order" type="number" min="0" max="100000" value="${escapeHtml(editing?.sort_order ?? "")}"></label>
|
||
</div>
|
||
<label class="hockey-directory-check"><input type="checkbox" name="team_penalty" ${editing?.team_penalty ? "checked" : ""}><span>Командное удаление — игрок не выбирается</span></label>
|
||
<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-penalty>Отмена</button>` : ""}
|
||
<button type="submit" class="is-accent">${editing ? "Сохранить" : "Добавить"}</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
`;
|
||
const modal = ensureModal();
|
||
modal.innerHTML = shellMarkup(content);
|
||
bindShell();
|
||
bindPenalties();
|
||
}
|
||
|
||
const genericSections = {
|
||
countries: { title: "Страны", endpoint: "countries", syncLabel: "Обновить players-countries.xml" },
|
||
players: { title: "Игроки", endpoint: "players", syncLabel: "Загрузить игроков лиги" },
|
||
referees: { title: "Судьи", endpoint: "referees", syncLabel: "Загрузить судей лиги" },
|
||
coaches: { title: "Тренеры", endpoint: "coaches", syncLabel: "Загрузить тренеров лиги" },
|
||
};
|
||
|
||
async function loadGeneric(section) {
|
||
const config = genericSections[section];
|
||
if (!config) return null;
|
||
const context = selectedContext();
|
||
const params = new URLSearchParams({ language: state.language, include_inactive: "true" });
|
||
if (state.directorySearch) params.set("search", state.directorySearch);
|
||
if (section !== "countries" && context.tournamentId) params.set("tournament_external_id", context.tournamentId);
|
||
const data = await request(`/api/hockey/directories/${config.endpoint}?${params}`);
|
||
state[section] = data;
|
||
return data;
|
||
}
|
||
|
||
function genericPersonFields(section, editing, context) {
|
||
const base = `
|
||
<label><span>ID из API</span><input name="external_id" maxlength="64" required value="${escapeHtml(editing?.external_id || "")}" ${editing ? "readonly" : ""}></label>
|
||
<div class="hockey-directory-form-columns">
|
||
<label><span>Полное имя (русский)</span><input name="full_name_ru" maxlength="500" value="${escapeHtml(editing?.full_name_ru || "")}"></label>
|
||
<label><span>Full name (English)</span><input name="full_name_en" maxlength="500" value="${escapeHtml(editing?.full_name_en || "")}"></label>
|
||
<label><span>Имя (русский)</span><input name="first_name_ru" maxlength="255" value="${escapeHtml(editing?.first_name_ru || "")}"></label>
|
||
<label><span>First name</span><input name="first_name_en" maxlength="255" value="${escapeHtml(editing?.first_name_en || "")}"></label>
|
||
<label><span>Фамилия (русский)</span><input name="last_name_ru" maxlength="255" value="${escapeHtml(editing?.last_name_ru || "")}"></label>
|
||
<label><span>Last name</span><input name="last_name_en" maxlength="255" value="${escapeHtml(editing?.last_name_en || "")}"></label>
|
||
<label><span>Дата рождения</span><input name="birth_date" type="date" value="${escapeHtml(editing?.birth_date || "")}"></label>
|
||
<label><span>Код страны</span><input name="country_code" maxlength="8" value="${escapeHtml(editing?.country_code || "")}"></label>
|
||
</div>`;
|
||
if (section === "players") return `${base}
|
||
<input type="hidden" name="tournament_external_id" value="${escapeHtml(context.tournamentId || editing?.tournament_external_id || "")}">
|
||
<div class="hockey-directory-form-columns">
|
||
<label><span>Амплуа (русский)</span><input name="position_ru" value="${escapeHtml(editing?.position_ru || "")}"></label>
|
||
<label><span>Position (English)</span><input name="position_en" value="${escapeHtml(editing?.position_en || "")}"></label>
|
||
<label><span>Рост, см</span><input name="height_cm" type="number" min="0" max="300" value="${escapeHtml(editing?.height_cm || "")}"></label>
|
||
<label><span>Вес, кг</span><input name="weight_kg" type="number" min="0" max="300" value="${escapeHtml(editing?.weight_kg || "")}"></label>
|
||
<label><span>Хват / stick</span><input name="stick" maxlength="32" value="${escapeHtml(editing?.stick || "")}"></label>
|
||
<label><span>Номер</span><input name="jersey_number" maxlength="16" value="${escapeHtml(editing?.jersey_number || "")}"></label>
|
||
<label><span>ID клуба</span><input name="club_external_id" maxlength="64" value="${escapeHtml(editing?.club_external_id || "")}"></label>
|
||
<label><span>ID команды турнира</span><input name="team_entry_external_id" maxlength="64" value="${escapeHtml(editing?.team_entry_external_id || "")}"></label>
|
||
<label><span>Команда (русский)</span><input name="team_name_ru" value="${escapeHtml(editing?.team_name_ru || "")}"></label>
|
||
<label><span>Team (English)</span><input name="team_name_en" value="${escapeHtml(editing?.team_name_en || "")}"></label>
|
||
</div>`;
|
||
if (section === "coaches") return `${base}
|
||
<input type="hidden" name="tournament_external_id" value="${escapeHtml(context.tournamentId || editing?.tournament_external_id || "")}">
|
||
<div class="hockey-directory-form-columns">
|
||
<label><span>ID команды</span><input name="team_external_id" maxlength="64" value="${escapeHtml(editing?.team_external_id || "")}"></label>
|
||
<label><span>Команда (русский)</span><input name="team_name_ru" value="${escapeHtml(editing?.team_name_ru || "")}"></label>
|
||
<label><span>Team (English)</span><input name="team_name_en" value="${escapeHtml(editing?.team_name_en || "")}"></label>
|
||
<label><span>Роль (русский)</span><input name="role_ru" value="${escapeHtml(editing?.role_ru || "Главный тренер")}"></label>
|
||
<label><span>Role (English)</span><input name="role_en" value="${escapeHtml(editing?.role_en || "Head coach")}"></label>
|
||
</div>`;
|
||
return `${base}
|
||
<div class="hockey-directory-form-columns">
|
||
<label><span>Роль / код</span><input name="position_code" maxlength="32" value="${escapeHtml(editing?.position_code || "")}"></label>
|
||
<label><span>Номер</span><input name="number" maxlength="16" value="${escapeHtml(editing?.number || "")}"></label>
|
||
<label><span>Страна (русский)</span><input name="country_ru" value="${escapeHtml(editing?.country_ru || "")}"></label>
|
||
<label><span>Country (English)</span><input name="country_en" value="${escapeHtml(editing?.country_en || "")}"></label>
|
||
<label><span>Город (русский)</span><input name="town_ru" value="${escapeHtml(editing?.town_ru || "")}"></label>
|
||
<label><span>Town (English)</span><input name="town_en" value="${escapeHtml(editing?.town_en || "")}"></label>
|
||
</div>`;
|
||
}
|
||
|
||
function renderGeneric(section) {
|
||
const config = genericSections[section];
|
||
const data = state[section] || { items: [] };
|
||
const context = selectedContext();
|
||
const editing = state.editingRecord;
|
||
const rows = data.items.length ? data.items.map((item) => {
|
||
if (section === "countries") return `<tr class="${item.active ? "" : "is-inactive"}"><td class="hockey-directory-flag">${countryFlagMarkup(item)}</td><td><code>${escapeHtml(item.iso2 || item.iso3 || item.external_id)}</code></td><td><strong>${escapeHtml(item.name_ru || "—")}</strong><small>${escapeHtml(item.name_en || "")}</small></td><td>${escapeHtml(item.iso3 || "—")}</td><td><button type="button" class="hockey-directory-edit" data-edit-record="${item.id}">Изменить</button></td></tr>`;
|
||
const team = item.team_name_ru || item.team_name_en || "";
|
||
const extras = section === "players"
|
||
? `${item.position_ru || item.position_en || "—"} · ${item.height_cm || "—"} см · ${item.weight_kg || "—"} кг · ${item.stick || "—"}`
|
||
: section === "referees" ? `${item.position_code || "—"} · № ${item.number || "—"}` : `${item.role_ru || item.role_en || "—"}`;
|
||
return `<tr class="${item.active ? "" : "is-inactive"}"><td class="hockey-directory-flag">${countryFlagMarkup(item)}</td><td><code>${escapeHtml(item.external_id)}</code></td><td><strong>${escapeHtml(item.full_name_ru || item.name || "—")}</strong><small>${escapeHtml(item.full_name_en || "")}</small></td><td><strong>${escapeHtml(team || "—")}</strong><small>${escapeHtml(extras)}</small></td><td>${item.birth_date ? `${escapeHtml(item.birth_date)}${item.age !== null && item.age !== undefined ? ` · ${escapeHtml(item.age)} лет` : ""}` : "—"}</td><td><button type="button" class="hockey-directory-edit" data-edit-record="${item.id}">Изменить</button></td></tr>`;
|
||
}).join("") : `<tr><td colspan="6" class="hockey-directory-empty">Данные ещё не загружены.</td></tr>`;
|
||
const needsTournament = section !== "countries";
|
||
const form = section === "countries" ? `
|
||
<label><span>ID / код API</span><input name="external_id" maxlength="64" required value="${escapeHtml(editing?.external_id || "")}" ${editing ? "readonly" : ""}></label>
|
||
<div class="hockey-directory-form-columns"><label><span>ISO-2</span><input name="iso2" maxlength="2" value="${escapeHtml(editing?.iso2 || "")}"></label><label><span>ISO-3</span><input name="iso3" maxlength="3" value="${escapeHtml(editing?.iso3 || "")}"></label><label><span>Название на русском</span><input name="name_ru" value="${escapeHtml(editing?.name_ru || "")}"></label><label><span>Name in English</span><input name="name_en" value="${escapeHtml(editing?.name_en || "")}"></label></div>` : genericPersonFields(section, editing, context);
|
||
const content = `
|
||
<div class="hockey-directory-toolbar">
|
||
<label class="hockey-directory-filter"><span>Поиск</span><input data-generic-search value="${escapeHtml(state.directorySearch)}" placeholder="Имя, ID, команда"></label>
|
||
<div class="hockey-directory-toolbar-actions"><button type="button" data-sync-generic ${needsTournament && !context.tournamentId ? "disabled" : ""}>${escapeHtml(config.syncLabel)}</button><button type="button" class="is-accent" data-new-record>Добавить</button></div>
|
||
</div>
|
||
<p class="hockey-directory-note">${needsTournament ? (context.tournamentId ? `Текущий турнир: ${escapeHtml(context.tournamentId)}.` : "Выберите матч или турнир, чтобы загрузить данные его лиги и сезона.") : "Глобальный справочник стран и связей игроков со странами."}</p>
|
||
${statusMarkup()}
|
||
<div class="hockey-directory-grid">
|
||
<div class="hockey-directory-table-wrap"><table><thead><tr>${section === "countries" ? "<th>Флаг</th><th>Код</th><th>Название</th><th>ISO-3</th><th></th>" : "<th>Флаг</th><th>ID</th><th>Имя</th><th>Команда / данные</th><th>Дата рождения</th><th></th>"}</tr></thead><tbody>${rows}</tbody></table></div>
|
||
<form class="hockey-directory-form" data-generic-form><h3>${editing ? `Редактирование: ${escapeHtml(config.title)}` : `Новая запись: ${escapeHtml(config.title)}`}</h3>${form}<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-record>Отмена</button>' : ""}<button type="submit" class="is-accent">${editing ? "Сохранить" : "Добавить"}</button></div></form>
|
||
</div>`;
|
||
const modal = ensureModal(); modal.innerHTML = shellMarkup(content); bindShell(); bindGeneric(section);
|
||
}
|
||
|
||
function formPayload(form) {
|
||
const values = new FormData(form); const payload = {};
|
||
for (const [key, value] of values.entries()) payload[key] = value;
|
||
["height_cm", "weight_kg"].forEach((key) => { if (key in payload) payload[key] = Number(payload[key] || 0); });
|
||
payload.active = values.has("active"); return payload;
|
||
}
|
||
|
||
function bindGeneric(section) {
|
||
const modal = ensureModal(); const config = genericSections[section];
|
||
modal.querySelector("[data-generic-search]")?.addEventListener("change", async (event) => { state.directorySearch = event.target.value.trim(); await loadGeneric(section); renderGeneric(section); });
|
||
modal.querySelector("[data-sync-generic]")?.addEventListener("click", async (event) => {
|
||
const button=event.currentTarget; const context=selectedContext(); const params=new URLSearchParams();
|
||
if (section !== "countries") params.set("tournament_external_id", context.tournamentId);
|
||
button.disabled=true;
|
||
try { const result=await request(`/api/hockey/directories/${config.endpoint}/sync?${params}`, {method:"POST"}); await loadGeneric(section); setStatus(`Справочник обновлён: ${result.players ?? result.referees ?? result.coaches ?? result.countries ?? state[section]?.count ?? 0} записей.`); }
|
||
catch(error){ setStatus(error.message,true); }
|
||
renderGeneric(section);
|
||
});
|
||
modal.querySelector("[data-new-record]")?.addEventListener("click",()=>{state.editingRecord=null;setStatus("");renderGeneric(section);});
|
||
modal.querySelectorAll("[data-edit-record]").forEach((button)=>button.addEventListener("click",()=>{state.editingRecord=(state[section]?.items||[]).find((item)=>String(item.id)===button.dataset.editRecord)||null;setStatus("");renderGeneric(section);}));
|
||
modal.querySelector("[data-cancel-record]")?.addEventListener("click",()=>{state.editingRecord=null;setStatus("");renderGeneric(section);});
|
||
modal.querySelector("[data-generic-form]")?.addEventListener("submit",async(event)=>{event.preventDefault();const payload=formPayload(event.currentTarget);const id=state.editingRecord?.id;try{await request(id?`/api/hockey/directories/${config.endpoint}/${id}`:`/api/hockey/directories/${config.endpoint}`,{method:id?"PUT":"POST",body:JSON.stringify(payload)});state.editingRecord=null;await loadGeneric(section);setStatus("Запись сохранена.");}catch(error){setStatus(error.message,true);}renderGeneric(section);});
|
||
}
|
||
|
||
function timerRuleModeOptions(selected) {
|
||
const value = String(selected || "pp");
|
||
return `
|
||
<option value="pp" ${value === "pp" ? "selected" : ""}>Только PP / PK</option>
|
||
<option value="numbers" ${value === "numbers" ? "selected" : ""}>Только состав: 5×4 / 4×3</option>
|
||
<option value="pp_numbers" ${value === "pp_numbers" ? "selected" : ""}>PP / PK вместе с составом</option>
|
||
`;
|
||
}
|
||
|
||
const STRENGTH_LABEL_PHASES = [
|
||
{ key: "regulation", title: "Основное время", hint: "Базовый состав 5×5", states: ["5x5", "5x4", "4x4", "5x3", "4x3", "3x3"] },
|
||
{ key: "regular_overtime", title: "Овертайм регулярки", hint: "Базовый состав 3×3; удаления добавляют игроков сопернику", states: ["3x3", "4x3", "4x4", "5x3", "5x4", "5x5"] },
|
||
{ key: "playoff_overtime", title: "Овертайм плей-офф", hint: "Базовый состав 5×5", states: ["5x5", "5x4", "4x4", "5x3", "4x3", "3x3"] },
|
||
];
|
||
|
||
const PERIOD_STATUS_ROWS = [
|
||
{ key: "1", title: "1 период" },
|
||
{ key: "2", title: "2 период" },
|
||
{ key: "3", title: "3 период" },
|
||
{ key: "ot", title: "Овертайм регулярки" },
|
||
{ key: "ot_numbered", title: "Номерной овертайм плей-офф", hint: "Можно использовать {n}" },
|
||
{ key: "so", title: "Буллиты" },
|
||
{ key: "finished", title: "Матч завершён" },
|
||
];
|
||
|
||
const SCOREBOARD_TEAM_STATE_ROWS = [
|
||
{ key: "home_delayed_penalty", side: "HOME", title: "Отложенный штраф", defaultRu: "Отложенный штраф", defaultEn: "Delayed penalty" },
|
||
{ key: "home_empty_net", side: "HOME", title: "Пустые ворота", defaultRu: "Пустые ворота", defaultEn: "Empty net" },
|
||
{ key: "away_delayed_penalty", side: "AWAY", title: "Отложенный штраф", defaultRu: "Отложенный штраф", defaultEn: "Delayed penalty" },
|
||
{ key: "away_empty_net", side: "AWAY", title: "Пустые ворота", defaultRu: "Пустые ворота", defaultEn: "Empty net" },
|
||
];
|
||
|
||
function timerRulesInventoryInputs() {
|
||
const inputs = state.timerRulesVmixInventory?.inventory?.inputs;
|
||
return Array.isArray(inputs) ? [...inputs] : [];
|
||
}
|
||
|
||
function scoreboardTeamStateValue(settings, key) {
|
||
const source = settings?.scoreboard_team_states?.[key];
|
||
return source && typeof source === "object" ? source : {};
|
||
}
|
||
|
||
function scoreboardInputOptions(selectedValue, selectedTitle = "") {
|
||
const selected = String(selectedValue || "").trim();
|
||
const inputs = timerRulesInventoryInputs();
|
||
const options = [`<option value="">— не выводить отдельный Input —</option>`];
|
||
let found = !selected;
|
||
inputs
|
||
.sort((a, b) => Number(a.number || 0) - Number(b.number || 0))
|
||
.forEach((input) => {
|
||
const key = String(input.key || input.title || input.number || "").trim();
|
||
if (!key) return;
|
||
const title = String(input.title || input.name || key).trim();
|
||
const number = String(input.number || "").trim();
|
||
const label = `${number ? `#${number} · ` : ""}${title}`;
|
||
if (key === selected) found = true;
|
||
options.push(`<option value="${escapeHtml(key)}" data-input-title="${escapeHtml(title)}" ${key === selected ? "selected" : ""}>${escapeHtml(label)}</option>`);
|
||
});
|
||
if (selected && !found) {
|
||
options.push(`<option value="${escapeHtml(selected)}" data-input-title="${escapeHtml(selectedTitle)}" selected>Сохранённый Input · ${escapeHtml(selectedTitle || selected)}</option>`);
|
||
}
|
||
return options.join("");
|
||
}
|
||
|
||
function scoreboardTeamStatesMarkup(settings) {
|
||
return SCOREBOARD_TEAM_STATE_ROWS.map((row) => {
|
||
const value = scoreboardTeamStateValue(settings, row.key);
|
||
const overlay = ["1", "2", "3", "4"].includes(String(value.overlay || "")) ? String(value.overlay) : (row.key.includes("empty_net") ? "3" : "2");
|
||
return `
|
||
<div class="hockey-team-state-setting-row">
|
||
<div class="hockey-team-state-setting-kind"><b>${escapeHtml(row.side)}</b><strong>${escapeHtml(row.title)}</strong></div>
|
||
<label><span>Русская надпись</span><input name="team_state_${row.key}_ru" maxlength="48" value="${escapeHtml(value.ru || row.defaultRu)}"></label>
|
||
<label><span>English label</span><input name="team_state_${row.key}_en" maxlength="48" value="${escapeHtml(value.en || row.defaultEn)}"></label>
|
||
<label><span>vMix Input</span><select name="team_state_${row.key}_input">${scoreboardInputOptions(value.input, value.input_title)}</select></label>
|
||
<label><span>Overlay</span><select name="team_state_${row.key}_overlay">${[1,2,3,4].map((item) => `<option value="${item}" ${String(item) === overlay ? "selected" : ""}>Overlay ${item}</option>`).join("")}</select></label>
|
||
<label class="hockey-rules-switch compact"><input name="team_state_${row.key}_enabled" type="checkbox" ${value.enabled !== false ? "checked" : ""}><i></i><span><strong>Использовать</strong></span></label>
|
||
</div>`;
|
||
}).join("");
|
||
}
|
||
|
||
function collectScoreboardTeamStates(values, form) {
|
||
const result = {};
|
||
SCOREBOARD_TEAM_STATE_ROWS.forEach((row) => {
|
||
const select = form.querySelector(`[name="team_state_${row.key}_input"]`);
|
||
const selectedOption = select?.selectedOptions?.[0];
|
||
result[row.key] = {
|
||
ru: String(values.get(`team_state_${row.key}_ru`) || "").trim(),
|
||
en: String(values.get(`team_state_${row.key}_en`) || "").trim(),
|
||
input: String(values.get(`team_state_${row.key}_input`) || "").trim(),
|
||
input_title: String(selectedOption?.dataset?.inputTitle || selectedOption?.textContent || "").replace(/^#\d+\s*·\s*/, "").replace(/^Сохранённый Input\s*·\s*/, "").trim(),
|
||
overlay: String(values.get(`team_state_${row.key}_overlay`) || "2"),
|
||
enabled: values.has(`team_state_${row.key}_enabled`),
|
||
};
|
||
});
|
||
return result;
|
||
}
|
||
|
||
function periodStatusLabelValue(settings, periodKey, formatKey, language) {
|
||
return String(settings?.period_status_labels?.[periodKey]?.[formatKey]?.[language] || "");
|
||
}
|
||
|
||
function periodStatusLabelsMarkup(settings) {
|
||
return PERIOD_STATUS_ROWS.map((row) => `
|
||
<div class="hockey-period-status-group">
|
||
<header><strong>${escapeHtml(row.title)}</strong>${row.hint ? `<small>${escapeHtml(row.hint)}</small>` : ""}</header>
|
||
<div class="hockey-period-status-table">
|
||
<div class="hockey-period-status-row is-head"><span>Формат</span><span>Русский</span><span>English</span></div>
|
||
${[["compact","Компактный"],["short","Короткий"],["long","Полный"]].map(([formatKey, label]) => `
|
||
<label class="hockey-period-status-row">
|
||
<b>${escapeHtml(label)}</b>
|
||
<input name="period_status_${row.key}_${formatKey}_ru" maxlength="64" value="${escapeHtml(periodStatusLabelValue(settings, row.key, formatKey, "ru"))}" placeholder="RU">
|
||
<input name="period_status_${row.key}_${formatKey}_en" maxlength="64" value="${escapeHtml(periodStatusLabelValue(settings, row.key, formatKey, "en"))}" placeholder="EN">
|
||
</label>`).join("")}
|
||
</div>
|
||
</div>`).join("");
|
||
}
|
||
|
||
function collectPeriodStatusLabels(values) {
|
||
const result = {};
|
||
PERIOD_STATUS_ROWS.forEach((row) => {
|
||
result[row.key] = {};
|
||
["compact", "short", "long"].forEach((formatKey) => {
|
||
result[row.key][formatKey] = {
|
||
ru: String(values.get(`period_status_${row.key}_${formatKey}_ru`) || "").trim(),
|
||
en: String(values.get(`period_status_${row.key}_${formatKey}_en`) || "").trim(),
|
||
};
|
||
});
|
||
});
|
||
return result;
|
||
}
|
||
|
||
function strengthStateLabelValue(settings, phase, stateKey, language) {
|
||
return String(settings?.strength_state_labels?.[phase]?.[stateKey]?.[language] || "");
|
||
}
|
||
|
||
function strengthStateLabelsMarkup(settings) {
|
||
return STRENGTH_LABEL_PHASES.map((phase) => `
|
||
<div class="hockey-strength-label-group">
|
||
<header><strong>${escapeHtml(phase.title)}</strong><small>${escapeHtml(phase.hint)}</small></header>
|
||
<div class="hockey-strength-label-table">
|
||
<div class="hockey-strength-label-row is-head"><span>Состав</span><span>Русская подпись</span><span>English label</span></div>
|
||
${phase.states.map((stateKey) => `
|
||
<label class="hockey-strength-label-row">
|
||
<b>${escapeHtml(stateKey.replace("x", "×"))}</b>
|
||
<input name="strength_state_${phase.key}_${stateKey}_ru" maxlength="40" value="${escapeHtml(strengthStateLabelValue(settings, phase.key, stateKey, "ru"))}" placeholder="Например: ${stateKey === "5x4" ? "PP" : stateKey.replace("x", " на ")}">
|
||
<input name="strength_state_${phase.key}_${stateKey}_en" maxlength="40" value="${escapeHtml(strengthStateLabelValue(settings, phase.key, stateKey, "en"))}" placeholder="Example: ${stateKey === "5x4" ? "PP" : stateKey.replace("x", " on ")}">
|
||
</label>`).join("")}
|
||
</div>
|
||
</div>`).join("");
|
||
}
|
||
|
||
function collectStrengthStateLabels(values) {
|
||
const result = {};
|
||
STRENGTH_LABEL_PHASES.forEach((phase) => {
|
||
result[phase.key] = {};
|
||
phase.states.forEach((stateKey) => {
|
||
result[phase.key][stateKey] = {
|
||
ru: String(values.get(`strength_state_${phase.key}_${stateKey}_ru`) || "").trim(),
|
||
en: String(values.get(`strength_state_${phase.key}_${stateKey}_en`) || "").trim(),
|
||
};
|
||
});
|
||
});
|
||
return result;
|
||
}
|
||
|
||
function renderTimerRules() {
|
||
const value = state.timerRules || {};
|
||
const modal = ensureModal();
|
||
modal.innerHTML = shellMarkup(`
|
||
<div class="hockey-rules-page">
|
||
<div class="hockey-rules-intro">
|
||
<div>
|
||
<span>ОБЩИЕ ПРАВИЛА МАТЧА</span>
|
||
<h2>Таймеры и численные составы</h2>
|
||
<p>Эти значения используются для новых матчей, кнопки «Сброс» и автоматической установки времени при смене периода.</p>
|
||
</div>
|
||
<button type="button" data-open-penalty-directory>Открыть справочник удалений</button>
|
||
</div>
|
||
${statusMarkup()}
|
||
<form class="hockey-rules-form" data-timer-rules-form>
|
||
<section class="hockey-rules-card">
|
||
<header><span>01</span><div><strong>Длительность периодов</strong><small>Укажите своё время в минутах.</small></div></header>
|
||
<div class="hockey-rules-fields three">
|
||
<label><span>Обычный период</span><div class="hockey-rules-number"><input name="timer_period_minutes" type="number" min="1" max="60" value="${Number(value.timer_period_minutes || 20)}"><b>мин</b></div><small>1, 2 и 3 периоды</small></label>
|
||
<label><span>Овертайм регулярки</span><div class="hockey-rules-number"><input name="timer_regular_overtime_minutes" type="number" min="1" max="60" value="${Number(value.timer_regular_overtime_minutes || 5)}"><b>мин</b></div><small>Обычно 5 минут</small></label>
|
||
<label><span>Овертайм плей-офф</span><div class="hockey-rules-number"><input name="timer_playoff_overtime_minutes" type="number" min="1" max="60" value="${Number(value.timer_playoff_overtime_minutes || 20)}"><b>мин</b></div><small>Каждый дополнительный период</small></label>
|
||
</div>
|
||
<label class="hockey-rules-switch">
|
||
<input name="timer_reset_on_period_change" type="checkbox" ${value.timer_reset_on_period_change !== false ? "checked" : ""}>
|
||
<i></i><span><strong>Автоматически ставить время нового периода</strong><small>При переключении периода таймер остановится и получит заданную выше длительность.</small></span>
|
||
</label>
|
||
</section>
|
||
|
||
<section class="hockey-rules-card">
|
||
<header><span>02</span><div><strong>Базовый численный состав</strong><small>Количество полевых игроков до удалений.</small></div></header>
|
||
<div class="hockey-rules-fields four">
|
||
<label><span>Основное время</span><div class="hockey-rules-number"><input name="strength_regulation_skaters" type="number" min="3" max="6" value="${Number(value.strength_regulation_skaters || 5)}"><b>×</b></div></label>
|
||
<label><span>ОТ регулярки</span><div class="hockey-rules-number"><input name="strength_regular_overtime_skaters" type="number" min="3" max="6" value="${Number(value.strength_regular_overtime_skaters || 3)}"><b>×</b></div></label>
|
||
<label><span>ОТ плей-офф</span><div class="hockey-rules-number"><input name="strength_playoff_overtime_skaters" type="number" min="3" max="6" value="${Number(value.strength_playoff_overtime_skaters || 5)}"><b>×</b></div></label>
|
||
<label><span>Минимум игроков</span><div class="hockey-rules-number"><input name="strength_min_skaters" type="number" min="2" max="5" value="${Number(value.strength_min_skaters || 3)}"><b>×</b></div></label>
|
||
</div>
|
||
<div class="hockey-rules-example">
|
||
<strong>Пример:</strong> при базовом составе 3×3 удаление одной команды отображает фактический состав 4×3 для соперника.
|
||
</div>
|
||
</section>
|
||
|
||
<section class="hockey-rules-card">
|
||
<header><span>03</span><div><strong>Подписи численных составов в верхнем счёте</strong><small>Для каждого состояния задаются отдельные русское и английское названия.</small></div></header>
|
||
<div class="hockey-rules-example">
|
||
<strong>Как работает:</strong> программа сама считает активные удаления. В основном времени одно удаление даёт 5×4, два удаления одной команды — 5×3, по одному у каждой — 4×4. В овертайме регулярки одно удаление даёт 4×3, два — 5×3, а по одному у каждой команды — 4×4.
|
||
</div>
|
||
<div class="hockey-strength-label-groups">${strengthStateLabelsMarkup(value)}</div>
|
||
</section>
|
||
|
||
<section class="hockey-rules-card">
|
||
<header><span>04</span><div><strong>Статус периода для Mapping</strong><small>Отдельные русские и английские подписи в трёх форматах. Для номерного овертайма доступен шаблон {n}.</small></div></header>
|
||
<div class="hockey-rules-example">
|
||
<strong>В Mapping:</strong> доступны period.compact, period.short, period.long, а также независимые period.ru.* и period.en.*. Можно сделать, например, «2», «2 ПЕР» и «2 период» одновременно для разных титров.
|
||
</div>
|
||
<div class="hockey-period-status-groups">${periodStatusLabelsMarkup(value)}</div>
|
||
</section>
|
||
|
||
<section class="hockey-rules-card">
|
||
<header><span>05</span><div><strong>Состояния команд в верхнем счёте</strong><small>Кнопки «Пустые ворота» и «Отложенный штраф»: подписи RU/EN и отдельные vMix Inputs.</small></div></header>
|
||
<div class="hockey-rules-example">
|
||
<strong>Логика:</strong> кнопка только включает состояние матча. Если верхний счёт уже в эфире, дополнительный Input включится/выключится сразу. Если счёт ещё не выдан, Input появится автоматически вместе с верхним счётом. В настройке сохраняется стабильный vMix key, а номер Input используется только для отображения.
|
||
</div>
|
||
<div class="hockey-team-state-settings">${scoreboardTeamStatesMarkup(value)}</div>
|
||
<small class="hockey-team-state-inventory-note">vMix: ${escapeHtml(state.timerRulesVmixInventory?.device_name || "Agent не выбран / inventory не загружен")}. Для обновления списка Inputs закройте и снова откройте этот раздел после выбора Agent.</small>
|
||
</section>
|
||
|
||
<footer class="hockey-rules-actions">
|
||
<div><small>Изменения применяются сразу и сохраняются в общих настройках сервера.</small><strong data-rules-save-status></strong></div>
|
||
<button type="submit">Сохранить настройки</button>
|
||
</footer>
|
||
</form>
|
||
</div>
|
||
`);
|
||
bindShell();
|
||
bindTimerRules();
|
||
}
|
||
|
||
function bindTimerRules() {
|
||
const modal = ensureModal();
|
||
modal.querySelector("[data-open-penalty-directory]")?.addEventListener("click", async () => {
|
||
state.section = "penalties";
|
||
state.status = "";
|
||
try { await loadPenalties(true); } catch (error) { setStatus(error.message, true); }
|
||
render();
|
||
});
|
||
modal.querySelector("[data-timer-rules-form]")?.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
const form = event.currentTarget;
|
||
const values = new FormData(form);
|
||
const button = form.querySelector("button[type='submit']");
|
||
const inlineStatus = form.querySelector("[data-rules-save-status]");
|
||
button.disabled = true;
|
||
inlineStatus.textContent = "Сохранение…";
|
||
try {
|
||
const payload = {
|
||
timer_period_minutes: Number(values.get("timer_period_minutes") || 20),
|
||
timer_regular_overtime_minutes: Number(values.get("timer_regular_overtime_minutes") || 5),
|
||
timer_playoff_overtime_minutes: Number(values.get("timer_playoff_overtime_minutes") || 20),
|
||
timer_reset_on_period_change: values.has("timer_reset_on_period_change"),
|
||
strength_regulation_skaters: Number(values.get("strength_regulation_skaters") || 5),
|
||
strength_regular_overtime_skaters: Number(values.get("strength_regular_overtime_skaters") || 3),
|
||
strength_playoff_overtime_skaters: Number(values.get("strength_playoff_overtime_skaters") || 5),
|
||
strength_min_skaters: Number(values.get("strength_min_skaters") || 3),
|
||
strength_state_labels: collectStrengthStateLabels(values),
|
||
period_status_labels: collectPeriodStatusLabels(values),
|
||
scoreboard_team_states: collectScoreboardTeamStates(values, form),
|
||
};
|
||
state.timerRules = await request("/api/hockey/settings", { method: "PUT", body: JSON.stringify(payload) });
|
||
inlineStatus.textContent = "Настройки сохранены";
|
||
inlineStatus.classList.remove("is-error");
|
||
window.dispatchEvent(new CustomEvent("hockey:settings-updated", { detail: { settings: state.timerRules } }));
|
||
} catch (error) {
|
||
inlineStatus.textContent = error.message;
|
||
inlineStatus.classList.add("is-error");
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
async function loadVmixSettings(force = false) {
|
||
if (state.vmixSettings && !force) return state.vmixSettings;
|
||
const [data, channelData] = await Promise.all([
|
||
request("/api/hockey/vmix/settings"),
|
||
request(`/api/hockey/vmix-channel?language=${encodeURIComponent(state.language === "en" ? "en" : "ru")}`).catch(() => ({ channel: null })),
|
||
]);
|
||
state.vmixSettings = data;
|
||
state.vmixChannel = channelData?.channel || null;
|
||
const configs = vmixConfigs();
|
||
if (!configs.some((item) => item.key === state.vmixActiveKey)) {
|
||
state.vmixActiveKey = String(data.settings?.active_vmix_json || configs[0]?.key || "");
|
||
}
|
||
return data;
|
||
}
|
||
|
||
function vmixConfigs() {
|
||
if (!state.vmixSettings) state.vmixSettings = { vmix_json: { version: 1, configs: [] }, vmix_functions: { categories: [] }, source_types: [] };
|
||
if (!state.vmixSettings.vmix_json || !Array.isArray(state.vmixSettings.vmix_json.configs)) {
|
||
state.vmixSettings.vmix_json = { version: 1, configs: [] };
|
||
}
|
||
return state.vmixSettings.vmix_json.configs;
|
||
}
|
||
|
||
function activeVmixConfig() {
|
||
return vmixConfigs().find((item) => item.key === state.vmixActiveKey) || vmixConfigs()[0] || null;
|
||
}
|
||
|
||
function vmixSafeKey(value, fallback = "json") {
|
||
return String(value || "").trim().replace(/[^A-Za-z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || fallback;
|
||
}
|
||
|
||
function vmixEndpointTemplate(key) {
|
||
return `/vmix/hockey/channel/{channel}/{language}/custom/${vmixSafeKey(key)}.json`;
|
||
}
|
||
|
||
function vmixLiveEndpoint(config) {
|
||
const context = selectedContext();
|
||
if (!context.token || !config) return "";
|
||
return `${location.origin}/vmix/hockey/channel/${encodeURIComponent(context.token)}/${context.language}/custom/${encodeURIComponent(config.key)}.json`;
|
||
}
|
||
|
||
function vmixSourceOptions(selected) {
|
||
const items = state.vmixSettings?.source_types || [];
|
||
return items.map((item) => `<option value="${escapeHtml(item.value)}" ${item.value === selected ? "selected" : ""}>${escapeHtml(item.label)} · ${escapeHtml(item.value)}</option>`).join("");
|
||
}
|
||
|
||
function vmixColumnsMarkup(config) {
|
||
const columns = Array.isArray(config?.columns) ? config.columns : [];
|
||
if (!columns.length) return `<tr><td colspan="8" class="hockey-vmix-empty">Колонки не заданы. В режиме «Все поля» исходные поля будут переданы полностью.</td></tr>`;
|
||
return columns.map((column, index) => `
|
||
<tr data-vmix-column="${index}">
|
||
<td><input type="checkbox" data-vmix-col="enabled" ${column.enabled !== false ? "checked" : ""}></td>
|
||
<td><input type="text" data-vmix-col="key" value="${escapeHtml(column.key || "")}"></td>
|
||
<td><input type="text" data-vmix-col="label" value="${escapeHtml(column.label || "")}"></td>
|
||
<td><input type="text" data-vmix-col="source" value="${escapeHtml(column.source || "")}"></td>
|
||
<td>
|
||
<select data-vmix-col="mode">
|
||
<option value="" ${!column.mode ? "selected" : ""}>auto</option>
|
||
<option value="field" ${column.mode === "field" ? "selected" : ""}>field</option>
|
||
<option value="template" ${column.mode === "template" ? "selected" : ""}>template</option>
|
||
<option value="expr" ${column.mode === "expr" ? "selected" : ""}>expr</option>
|
||
</select>
|
||
</td>
|
||
<td><input type="text" data-vmix-col="expr" value="${escapeHtml(column.expr || "")}" placeholder="if_eq(period, '1', 'I', period)"></td>
|
||
<td><input type="text" data-vmix-col="default" value="${escapeHtml(column.default || "")}"></td>
|
||
<td class="hockey-vmix-col-actions">
|
||
<button type="button" data-vmix-col-action="up" data-index="${index}" aria-label="Вверх">↑</button>
|
||
<button type="button" data-vmix-col-action="down" data-index="${index}" aria-label="Вниз">↓</button>
|
||
<button type="button" data-vmix-col-action="delete" data-index="${index}" aria-label="Удалить">×</button>
|
||
</td>
|
||
</tr>
|
||
`).join("");
|
||
}
|
||
|
||
function vmixConfigPageMarkup() {
|
||
const configs = vmixConfigs();
|
||
const config = activeVmixConfig();
|
||
const context = selectedContext();
|
||
const liveEndpoint = vmixLiveEndpoint(config);
|
||
const sourceFields = state.vmixSourceFields?.sourceType === config?.source_type ? state.vmixSourceFields.fields : [];
|
||
return `
|
||
<div class="hockey-vmix-layout">
|
||
<aside class="hockey-vmix-list-panel">
|
||
<div class="hockey-vmix-list-head"><strong>JSON-конфигурации</strong><small>${configs.length} шт.</small></div>
|
||
<div class="hockey-vmix-config-list">
|
||
${configs.map((item) => `
|
||
<button type="button" data-vmix-config="${escapeHtml(item.key)}" class="${item.key === state.vmixActiveKey ? "is-active" : ""}">
|
||
<strong>${escapeHtml(item.title || item.key)}</strong>
|
||
<span>${escapeHtml(item.key)}</span>
|
||
<em>${escapeHtml(item.source_type || "scoreboard")}</em>
|
||
</button>
|
||
`).join("") || `<div class="hockey-vmix-empty">Конфигураций пока нет.</div>`}
|
||
</div>
|
||
<div class="hockey-vmix-list-actions">
|
||
<button type="button" data-vmix-new>+ Новый</button>
|
||
<button type="button" data-vmix-duplicate ${config ? "" : "disabled"}>Копия</button>
|
||
<button type="button" data-vmix-delete ${config ? "" : "disabled"}>Удалить</button>
|
||
</div>
|
||
</aside>
|
||
<section class="hockey-vmix-editor">
|
||
${config ? `
|
||
<header class="hockey-vmix-editor-head">
|
||
<div><span>VMIX JSON EDITOR</span><strong>${escapeHtml(config.title || config.key)}</strong></div>
|
||
<div class="hockey-vmix-editor-actions">
|
||
<button type="button" data-vmix-fields>Поля источника</button>
|
||
<button type="button" data-vmix-preview ${liveEndpoint ? "" : "disabled"}>Открыть JSON</button>
|
||
<button type="button" class="is-accent" data-vmix-save>Сохранить</button>
|
||
</div>
|
||
</header>
|
||
<div class="hockey-vmix-form-grid">
|
||
<label><span>Ключ JSON</span><input data-vmix-main="key" value="${escapeHtml(config.key || "")}"></label>
|
||
<label><span>Название</span><input data-vmix-main="title" value="${escapeHtml(config.title || "")}"></label>
|
||
<label class="is-wide"><span>Адрес-шаблон</span><input data-vmix-main="endpoint" value="${escapeHtml(vmixEndpointTemplate(config.key))}" readonly></label>
|
||
<label><span>Источник</span><select data-vmix-main="source_type">${vmixSourceOptions(config.source_type)}</select></label>
|
||
<label><span>Вывод</span><select data-vmix-main="output_mode"><option value="columns" ${config.output_mode !== "all" ? "selected" : ""}>Только выбранные колонки</option><option value="all" ${config.output_mode === "all" ? "selected" : ""}>Все поля</option></select></label>
|
||
<label><span>Лимит строк, 0 — без лимита</span><input type="number" min="0" max="1000" data-vmix-main="default_limit" value="${Number(config.default_limit || 0)}"></label>
|
||
<label class="hockey-directory-check"><input type="checkbox" data-vmix-main="enabled" ${config.enabled !== false ? "checked" : ""}><span>Конфигурация включена</span></label>
|
||
</div>
|
||
<div class="hockey-vmix-live-link ${liveEndpoint ? "" : "is-disabled"}">
|
||
<span>${context.token ? `Постоянный канал аккаунта ${escapeHtml(state.vmixChannel?.channel_owner || "")}` : "Сначала откройте матч, чтобы создать персональный канал vMix"}</span>
|
||
<code>${escapeHtml(liveEndpoint || vmixEndpointTemplate(config.key))}</code>
|
||
</div>
|
||
<div class="hockey-vmix-fields ${sourceFields.length ? "" : "hidden"}" data-vmix-fields-box>
|
||
${sourceFields.map((field) => `<button type="button" data-vmix-source-field="${escapeHtml(field)}">${escapeHtml(field)}</button>`).join("")}
|
||
</div>
|
||
<div class="hockey-vmix-columns-head">
|
||
<div><strong>Колонки JSON</strong><small>field — поле, template — шаблон, expr — формула</small></div>
|
||
<button type="button" data-vmix-add-column>+ Колонка</button>
|
||
</div>
|
||
<div class="hockey-vmix-table-wrap">
|
||
<table class="hockey-vmix-columns-table">
|
||
<thead><tr><th>Вкл</th><th>key</th><th>label</th><th>source</th><th>mode</th><th>expr</th><th>default</th><th></th></tr></thead>
|
||
<tbody>${vmixColumnsMarkup(config)}</tbody>
|
||
</table>
|
||
</div>
|
||
<details class="hockey-vmix-joins">
|
||
<summary>Объединение дополнительных источников</summary>
|
||
<p>Необязательный массив правил joins из переносимого модуля. Источник указывается как <code>source_type</code>.</p>
|
||
<textarea data-vmix-joins spellcheck="false">${escapeHtml(JSON.stringify(config.joins || [], null, 2))}</textarea>
|
||
</details>
|
||
` : `<div class="hockey-vmix-empty is-large">Создайте первую JSON-конфигурацию.</div>`}
|
||
</section>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function vmixFunctionsMarkup() {
|
||
const categories = state.vmixSettings?.vmix_functions?.categories || [];
|
||
return `<div class="hockey-vmix-functions">${categories.map((category) => `
|
||
<details open>
|
||
<summary>${escapeHtml(category.title || "Функции")}</summary>
|
||
<div>${(category.items || []).map((item) => `<article><code>${escapeHtml(item.example || item.name || "")}</code><span>${escapeHtml(item.description || "")}</span></article>`).join("")}</div>
|
||
</details>
|
||
`).join("") || `<div class="hockey-vmix-empty">Справочник функций пуст.</div>`}</div>`;
|
||
}
|
||
|
||
function vmixImportMarkup() {
|
||
const preview = state.vmixImportPreview;
|
||
const configs = preview?.configs || preview?.configs_preview || preview?.vmix_configs || [];
|
||
return `
|
||
<div class="hockey-vmix-import">
|
||
<section>
|
||
<span>IMPORT</span><strong>Импорт настроек</strong>
|
||
<p>Можно выбрать <code>vmix_json.json</code>, другой JSON с настройками или ZIP проекта. Перед применением показывается найденный список конфигураций.</p>
|
||
<div class="hockey-vmix-import-actions">
|
||
<input type="file" data-vmix-import-file accept=".json,.zip,application/json,application/zip">
|
||
<button type="button" data-vmix-import-preview>Предпросмотр</button>
|
||
<button type="button" class="is-accent" data-vmix-import-apply ${preview ? "" : "disabled"}>Добавить конфигурации</button>
|
||
</div>
|
||
${preview ? `<div class="hockey-vmix-import-preview"><strong>${escapeHtml(preview.file_name || "Файл")}</strong><span>Найдено: ${Number(preview.configs_count ?? configs.length ?? 0)}</span><div>${configs.map((item) => `<em>${escapeHtml(item.title || item.key || "JSON")}</em>`).join("")}</div></div>` : ""}
|
||
</section>
|
||
<section>
|
||
<span>EXPORT</span><strong>Экспорт текущих настроек</strong>
|
||
<p>Скачивается переносимый JSON, который можно импортировать в другую хоккейную сборку.</p>
|
||
<button type="button" data-vmix-export>Скачать vmix_json.json</button>
|
||
</section>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderVmixSettings() {
|
||
const panelContent = state.vmixPanel === "functions"
|
||
? vmixFunctionsMarkup()
|
||
: state.vmixPanel === "import"
|
||
? vmixImportMarkup()
|
||
: vmixConfigPageMarkup();
|
||
const content = `
|
||
<div class="hockey-vmix-page">
|
||
<div class="hockey-vmix-intro">
|
||
<div><span>PORTABLE VMIX SETTINGS</span><h2>Конструктор JSON для vMix</h2><p>Каждый аккаунт имеет постоянный канал vMix. При выборе нового матча данные меняются, а ссылка в проекте vMix остаётся прежней.</p></div>
|
||
<nav>
|
||
<button type="button" data-vmix-panel="configs" class="${state.vmixPanel === "configs" ? "is-active" : ""}">JSON</button>
|
||
<button type="button" data-vmix-panel="functions" class="${state.vmixPanel === "functions" ? "is-active" : ""}">Формулы</button>
|
||
<button type="button" data-vmix-panel="import" class="${state.vmixPanel === "import" ? "is-active" : ""}">Импорт / экспорт</button>
|
||
</nav>
|
||
</div>
|
||
${statusMarkup()}
|
||
${panelContent}
|
||
</div>
|
||
`;
|
||
const modal = ensureModal();
|
||
modal.innerHTML = shellMarkup(content);
|
||
bindShell();
|
||
bindVmixSettings();
|
||
}
|
||
|
||
function collectVmixEditor() {
|
||
const config = activeVmixConfig();
|
||
const modal = ensureModal();
|
||
if (!config || state.vmixPanel !== "configs") return true;
|
||
const oldKey = config.key;
|
||
const key = vmixSafeKey(modal.querySelector('[data-vmix-main="key"]')?.value, oldKey || "json");
|
||
config.key = key;
|
||
config.title = modal.querySelector('[data-vmix-main="title"]')?.value.trim() || key;
|
||
config.endpoint = vmixEndpointTemplate(key);
|
||
config.source_type = modal.querySelector('[data-vmix-main="source_type"]')?.value || "scoreboard";
|
||
config.output_mode = modal.querySelector('[data-vmix-main="output_mode"]')?.value === "all" ? "all" : "columns";
|
||
config.default_limit = Math.max(0, Number(modal.querySelector('[data-vmix-main="default_limit"]')?.value || 0));
|
||
config.enabled = modal.querySelector('[data-vmix-main="enabled"]')?.checked !== false;
|
||
config.columns = Array.from(modal.querySelectorAll("[data-vmix-column]")).map((row) => ({
|
||
enabled: row.querySelector('[data-vmix-col="enabled"]')?.checked !== false,
|
||
key: row.querySelector('[data-vmix-col="key"]')?.value.trim() || "",
|
||
label: row.querySelector('[data-vmix-col="label"]')?.value.trim() || "",
|
||
source: row.querySelector('[data-vmix-col="source"]')?.value || "",
|
||
mode: row.querySelector('[data-vmix-col="mode"]')?.value || "",
|
||
expr: row.querySelector('[data-vmix-col="expr"]')?.value || "",
|
||
default: row.querySelector('[data-vmix-col="default"]')?.value || "",
|
||
}));
|
||
const joinsText = modal.querySelector("[data-vmix-joins]")?.value.trim() || "[]";
|
||
try {
|
||
const joins = JSON.parse(joinsText);
|
||
if (!Array.isArray(joins)) throw new Error("joins должен быть массивом");
|
||
config.joins = joins;
|
||
} catch (error) {
|
||
setStatus(`Ошибка в joins: ${error.message}`, true);
|
||
return false;
|
||
}
|
||
if (oldKey !== key) state.vmixActiveKey = key;
|
||
return true;
|
||
}
|
||
|
||
function createVmixConfig(copy = false) {
|
||
collectVmixEditor();
|
||
const current = activeVmixConfig();
|
||
const config = copy && current ? JSON.parse(JSON.stringify(current)) : {
|
||
key: "new_json", title: "Новый JSON", description: "", source_type: "scoreboard", output_mode: "columns", root_key: "rows", default_limit: 0, enabled: true, columns: [], joins: [],
|
||
};
|
||
const used = new Set(vmixConfigs().map((item) => item.key));
|
||
const base = vmixSafeKey(config.key, "new_json");
|
||
let key = base;
|
||
let index = 2;
|
||
while (used.has(key)) key = `${base}_${index++}`;
|
||
config.key = key;
|
||
config.title = copy ? `${config.title || base} — копия` : config.title;
|
||
config.endpoint = vmixEndpointTemplate(key);
|
||
vmixConfigs().push(config);
|
||
state.vmixActiveKey = key;
|
||
state.vmixSourceFields = null;
|
||
renderVmixSettings();
|
||
}
|
||
|
||
async function saveVmixConfigs() {
|
||
if (!collectVmixEditor()) {
|
||
renderVmixSettings();
|
||
return;
|
||
}
|
||
try {
|
||
const data = await request("/api/hockey/vmix/configs", { method: "PUT", body: JSON.stringify({ vmix_json: state.vmixSettings.vmix_json }) });
|
||
state.vmixSettings.vmix_json = data.vmix_json;
|
||
state.vmixActiveKey = activeVmixConfig()?.key || state.vmixSettings.vmix_json.configs[0]?.key || "";
|
||
setStatus("Настройки vMix сохранены.");
|
||
} catch (error) {
|
||
setStatus(error.message, true);
|
||
}
|
||
renderVmixSettings();
|
||
}
|
||
|
||
async function loadVmixSourceFields() {
|
||
if (!collectVmixEditor()) return renderVmixSettings();
|
||
const config = activeVmixConfig();
|
||
const context = selectedContext();
|
||
if (!context.token) {
|
||
setStatus("Сначала откройте любой матч: после этого для аккаунта будет создан постоянный канал vMix.", true);
|
||
return renderVmixSettings();
|
||
}
|
||
try {
|
||
const params = new URLSearchParams({ token: context.token, language: context.language, source_type: config.source_type || "scoreboard" });
|
||
const data = await request(`/api/hockey/vmix/source-fields?${params}`);
|
||
state.vmixSourceFields = { sourceType: config.source_type, fields: data.fields || [] };
|
||
setStatus(`Найдено полей: ${(data.fields || []).length}. Нажмите на поле, чтобы добавить колонку.`);
|
||
} catch (error) {
|
||
setStatus(error.message, true);
|
||
}
|
||
renderVmixSettings();
|
||
}
|
||
|
||
function fileToBase64(file) {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => resolve(String(reader.result || "").split(",")[1] || "");
|
||
reader.onerror = reject;
|
||
reader.readAsDataURL(file);
|
||
});
|
||
}
|
||
|
||
async function previewVmixImport() {
|
||
const file = ensureModal().querySelector("[data-vmix-import-file]")?.files?.[0];
|
||
if (!file) {
|
||
setStatus("Выберите JSON или ZIP.", true);
|
||
return renderVmixSettings();
|
||
}
|
||
try {
|
||
const contentBase64 = await fileToBase64(file);
|
||
state.vmixImportPreview = await request("/api/hockey/vmix/import/preview-file", { method: "POST", body: JSON.stringify({ file_name: file.name, content_base64: contentBase64 }) });
|
||
setStatus("Предпросмотр импорта готов.");
|
||
} catch (error) {
|
||
state.vmixImportPreview = null;
|
||
setStatus(error.message, true);
|
||
}
|
||
renderVmixSettings();
|
||
}
|
||
|
||
async function applyVmixImport() {
|
||
if (!state.vmixImportPreview) return;
|
||
try {
|
||
const data = await request("/api/hockey/vmix/import/apply", { method: "POST", body: JSON.stringify({ preview: state.vmixImportPreview, import_vmix: true, mode: "add_new" }) });
|
||
state.vmixSettings.vmix_json = data.vmix_json;
|
||
state.vmixActiveKey = data.vmix_json?.configs?.[0]?.key || "";
|
||
state.vmixImportPreview = null;
|
||
setStatus("Конфигурации импортированы и адаптированы к хоккейным источникам.");
|
||
state.vmixPanel = "configs";
|
||
} catch (error) {
|
||
setStatus(error.message, true);
|
||
}
|
||
renderVmixSettings();
|
||
}
|
||
|
||
async function exportVmixSettings() {
|
||
try {
|
||
const data = await request("/api/hockey/vmix/export");
|
||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement("a");
|
||
link.href = url;
|
||
link.download = "hockey_vmix_json.json";
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
link.remove();
|
||
URL.revokeObjectURL(url);
|
||
setStatus("Настройки экспортированы.");
|
||
} catch (error) {
|
||
setStatus(error.message, true);
|
||
}
|
||
renderVmixSettings();
|
||
}
|
||
|
||
function bindVmixSettings() {
|
||
const modal = ensureModal();
|
||
modal.querySelectorAll("[data-vmix-panel]").forEach((button) => button.addEventListener("click", () => {
|
||
if (state.vmixPanel === "configs" && !collectVmixEditor()) return renderVmixSettings();
|
||
state.vmixPanel = button.dataset.vmixPanel;
|
||
state.status = "";
|
||
renderVmixSettings();
|
||
}));
|
||
modal.querySelectorAll("[data-vmix-config]").forEach((button) => button.addEventListener("click", () => {
|
||
if (!collectVmixEditor()) return renderVmixSettings();
|
||
state.vmixActiveKey = button.dataset.vmixConfig;
|
||
state.vmixSourceFields = null;
|
||
state.status = "";
|
||
renderVmixSettings();
|
||
}));
|
||
modal.querySelector("[data-vmix-new]")?.addEventListener("click", () => createVmixConfig(false));
|
||
modal.querySelector("[data-vmix-duplicate]")?.addEventListener("click", () => createVmixConfig(true));
|
||
modal.querySelector("[data-vmix-delete]")?.addEventListener("click", () => {
|
||
const config = activeVmixConfig();
|
||
if (!config || !confirm(`Удалить JSON «${config.title || config.key}»?`)) return;
|
||
const index = vmixConfigs().indexOf(config);
|
||
vmixConfigs().splice(index, 1);
|
||
state.vmixActiveKey = vmixConfigs()[Math.max(0, index - 1)]?.key || vmixConfigs()[0]?.key || "";
|
||
state.vmixSourceFields = null;
|
||
renderVmixSettings();
|
||
});
|
||
modal.querySelector("[data-vmix-save]")?.addEventListener("click", saveVmixConfigs);
|
||
modal.querySelector("[data-vmix-fields]")?.addEventListener("click", loadVmixSourceFields);
|
||
modal.querySelector("[data-vmix-preview]")?.addEventListener("click", () => {
|
||
if (!collectVmixEditor()) return renderVmixSettings();
|
||
const endpoint = vmixLiveEndpoint(activeVmixConfig());
|
||
if (endpoint) window.open(endpoint, "_blank", "noopener");
|
||
});
|
||
modal.querySelector("[data-vmix-add-column]")?.addEventListener("click", () => {
|
||
if (!collectVmixEditor()) return renderVmixSettings();
|
||
const config = activeVmixConfig();
|
||
config.columns.push({ key: `field_${config.columns.length + 1}`, label: "", source: "", mode: "field", expr: "", default: "", enabled: true });
|
||
config.output_mode = "columns";
|
||
renderVmixSettings();
|
||
});
|
||
modal.querySelectorAll("[data-vmix-col-action]").forEach((button) => button.addEventListener("click", () => {
|
||
if (!collectVmixEditor()) return renderVmixSettings();
|
||
const config = activeVmixConfig();
|
||
const index = Number(button.dataset.index);
|
||
const action = button.dataset.vmixColAction;
|
||
if (action === "delete") config.columns.splice(index, 1);
|
||
if (action === "up" && index > 0) config.columns.splice(index - 1, 0, config.columns.splice(index, 1)[0]);
|
||
if (action === "down" && index < config.columns.length - 1) config.columns.splice(index + 1, 0, config.columns.splice(index, 1)[0]);
|
||
renderVmixSettings();
|
||
}));
|
||
modal.querySelectorAll("[data-vmix-source-field]").forEach((button) => button.addEventListener("click", () => {
|
||
if (!collectVmixEditor()) return renderVmixSettings();
|
||
const field = button.dataset.vmixSourceField || "field";
|
||
activeVmixConfig().columns.push({ key: vmixSafeKey(field.replaceAll(".", "_"), "field"), label: field, source: field, mode: "field", expr: "", default: "", enabled: true });
|
||
activeVmixConfig().output_mode = "columns";
|
||
renderVmixSettings();
|
||
}));
|
||
modal.querySelector('[data-vmix-main="source_type"]')?.addEventListener("change", () => { state.vmixSourceFields = null; });
|
||
modal.querySelector("[data-vmix-import-preview]")?.addEventListener("click", previewVmixImport);
|
||
modal.querySelector("[data-vmix-import-apply]")?.addEventListener("click", applyVmixImport);
|
||
modal.querySelector("[data-vmix-export]")?.addEventListener("click", exportVmixSettings);
|
||
}
|
||
|
||
function mappingRequestContext() {
|
||
const runtime = window.UIBuilderRuntime?.getData?.()?.hockey || {};
|
||
const selected = selectedContext();
|
||
const game = runtime.selected_game || selected.game || {};
|
||
const tournament = runtime.selected_tournament || selected.tournament || {};
|
||
const selectedPlayer = runtime.selected_player || runtime.player || runtime.match_details?.selected_player || {};
|
||
const home = runtime.home || game.home || {};
|
||
const away = runtime.away || game.away || {};
|
||
const value = (obj, keys) => {
|
||
for (const key of keys) {
|
||
const parts = String(key).split(".");
|
||
let current = obj; let ok = true;
|
||
for (const part of parts) {
|
||
if (current == null || !(part in Object(current))) { ok = false; break; }
|
||
current = current[part];
|
||
}
|
||
if (ok && current !== null && current !== undefined && String(current) !== "") return String(current);
|
||
}
|
||
return "";
|
||
};
|
||
return {
|
||
game_id: value(game, ["external_id", "id"]),
|
||
tournament_id: value(game, ["tournament_external_id"]) || value(tournament, ["external_id", "id"]) || selected.tournamentId || "",
|
||
team1_id: value(game, ["home_team_external_id"]) || value(home, ["external_id", "id", "team_id"]),
|
||
team2_id: value(game, ["away_team_external_id"]) || value(away, ["external_id", "id", "team_id"]),
|
||
selected_player_id: value(selectedPlayer, ["external_id", "id", "player_id"]),
|
||
selected_player_team_id: value(selectedPlayer, ["team.external_id", "team.id", "team_id"]),
|
||
ui_language: String(runtime.language?.display || state.language || "ru").toLowerCase() === "en" ? "en" : "ru",
|
||
session_token: selected.token || "",
|
||
};
|
||
}
|
||
|
||
async function loadMappingCatalog() {
|
||
try {
|
||
state.mappingCatalog = await request("/api/hockey/admin/mapping-data/catalog", {
|
||
method: "POST", body: JSON.stringify({ context: mappingRequestContext() }),
|
||
});
|
||
} catch (error) {
|
||
state.mappingCatalog = { items: [], variables: [], sources: [], context: mappingRequestContext(), error: error.message || String(error) };
|
||
}
|
||
return state.mappingCatalog;
|
||
}
|
||
|
||
async function loadMapping(profileId = null) {
|
||
state.mappingWorkspaceTab = mappingTabFromSection();
|
||
state.mappingLoadErrors = {};
|
||
const safe = async (key, url, fallback) => {
|
||
try { return await request(url); }
|
||
catch (error) { state.mappingLoadErrors[key] = error.message || String(error); return fallback; }
|
||
};
|
||
const [devices, profiles, variables, sources] = await Promise.all([
|
||
safe("devices", "/api/hockey/admin/vmix-mapping/devices", { devices: [] }),
|
||
safe("profiles", "/api/hockey/admin/vmix-mapping/profiles", { profiles: [] }),
|
||
safe("variables", "/api/hockey/admin/mapping-context/variables", { variables: [] }),
|
||
safe("sources", "/api/hockey/admin/mapping-data/sources", { sources: [] }),
|
||
]);
|
||
state.mappingDevices = devices;
|
||
state.mappingProfiles = profiles;
|
||
state.mappingContextVariables = variables;
|
||
state.mappingSqlSources = sources;
|
||
const id = profileId ?? state.mappingActiveProfile?.id ?? profiles.profiles?.[0]?.id ?? null;
|
||
if (id) {
|
||
try { state.mappingActiveProfile = await request(`/api/hockey/admin/vmix-mapping/profiles/${id}`); }
|
||
catch (error) { state.mappingActiveProfile = null; state.mappingLoadErrors.profile = error.message || String(error); }
|
||
} else state.mappingActiveProfile = null;
|
||
if (!state.mappingSelectedSqlSourceId && sources.sources?.length) state.mappingSelectedSqlSourceId = sources.sources[0].id;
|
||
await loadMappingCatalog();
|
||
if (state.mappingCatalog?.error) state.mappingLoadErrors.catalog = state.mappingCatalog.error;
|
||
return { devices, profiles, variables, sources };
|
||
}
|
||
|
||
function mappingInventoryInputs(profile) {
|
||
// Mapping picker is operator-facing: show Inputs in vMix numeric order so a
|
||
// title can be found by its familiar # quickly. Stable links still use key/title.
|
||
const inputs = Array.isArray(profile?.inventory?.inputs) ? [...profile.inventory.inputs] : [];
|
||
return inputs.sort((left, right) => {
|
||
const leftNumber = Number(left?.number);
|
||
const rightNumber = Number(right?.number);
|
||
const leftRank = Number.isFinite(leftNumber) ? leftNumber : Number.MAX_SAFE_INTEGER;
|
||
const rightRank = Number.isFinite(rightNumber) ? rightNumber : Number.MAX_SAFE_INTEGER;
|
||
return leftRank - rightRank
|
||
|| String(left?.title || "").localeCompare(String(right?.title || ""), "ru", { numeric: true, sensitivity: "base" });
|
||
});
|
||
}
|
||
|
||
function mappingVmixFieldOrder(left, right) {
|
||
// Natural sort keeps Player2.Text before Player10.Text and makes long GT
|
||
// templates much easier to scan. Type suffix stays part of the comparison.
|
||
return String(left?.name || "").localeCompare(String(right?.name || ""), "ru", {
|
||
numeric: true,
|
||
sensitivity: "base",
|
||
});
|
||
}
|
||
|
||
function mappingInputByKey(profile, key, title = "") {
|
||
const inputs = mappingInventoryInputs(profile);
|
||
return inputs.find((item) => String(item.key || item.title || item.number || "") === String(key || ""))
|
||
|| inputs.find((item) => String(item.title || "") === String(title || ""))
|
||
|| null;
|
||
}
|
||
|
||
function mappingInputValue(item) {
|
||
return String(item?.key || item?.title || item?.number || "");
|
||
}
|
||
|
||
function mappingInputLabel(item) {
|
||
if (!item) return "—";
|
||
return `${item.number ? `#${item.number} · ` : ""}${item.title || "Без названия"}${item.type ? ` [${item.type}]` : ""}`;
|
||
}
|
||
|
||
function mappingFilteredInputs(profile) {
|
||
const inputs = mappingInventoryInputs(profile);
|
||
const query = String(state.mappingInputSearch || "").trim().toLowerCase();
|
||
if (!query) return inputs;
|
||
return inputs.filter((item) => `${item.number || ""} ${item.title || ""} ${item.key || ""} ${item.type || ""}`.toLowerCase().includes(query));
|
||
}
|
||
|
||
function mappingFieldKind(field) {
|
||
const name = String(field?.name || "").trim().toLowerCase();
|
||
if (name.endsWith(".source")) return "image";
|
||
if (name.endsWith(".color")) return "color";
|
||
if (name.endsWith(".text")) return "text";
|
||
const raw = String(field?.type || "text").toLowerCase();
|
||
if (raw.includes("image") || raw.includes("source")) return "image";
|
||
if (raw.includes("color")) return "color";
|
||
return "text";
|
||
}
|
||
|
||
function mappingFieldFilterMatch(field, filter) {
|
||
const name = String(field?.name || "").trim().toLowerCase();
|
||
if (filter === "image") return name.endsWith(".source");
|
||
if (filter === "color") return name.endsWith(".color");
|
||
return name.endsWith(".text");
|
||
}
|
||
|
||
function mappingPathValue(root, paths, fallback = "") {
|
||
for (const path of paths) {
|
||
let value = root;
|
||
let ok = true;
|
||
for (const part of String(path).split(".")) {
|
||
if (value == null || !(part in Object(value))) { ok = false; break; }
|
||
value = value[part];
|
||
}
|
||
if (ok && value !== null && value !== undefined && value !== "") return value;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
function mappingDisplayValue(value, kind = "text") {
|
||
if (value === null || value === undefined || value === "") return "—";
|
||
if (typeof value === "boolean") return value ? "Да" : "Нет";
|
||
if (Array.isArray(value)) return value.length ? `${value.length} элементов` : "—";
|
||
if (typeof value === "object") {
|
||
const text = value.name || value.title || value.label || value.full_name || value.value;
|
||
return text ? String(text) : "Данные доступны";
|
||
}
|
||
const text = String(value);
|
||
if (kind === "image") {
|
||
const part = text.split(/[\\/]/).pop() || text;
|
||
return part.length > 52 ? `${part.slice(0, 49)}…` : part;
|
||
}
|
||
return text.length > 74 ? `${text.slice(0, 71)}…` : text;
|
||
}
|
||
|
||
function mappingSource(key, label, category, value, options = {}) {
|
||
return {
|
||
key,
|
||
label,
|
||
category,
|
||
value,
|
||
kind: options.kind || "text",
|
||
description: options.description || "",
|
||
};
|
||
}
|
||
|
||
function mappingDataCatalog() {
|
||
const payload = state.mappingCatalog || {};
|
||
const items = Array.isArray(payload.items) ? payload.items.map((item) => ({
|
||
key: String(item.key || ""),
|
||
label: String(item.label || item.key || "Данные"),
|
||
category: String(item.category || "Данные"),
|
||
value: item.value,
|
||
kind: String(item.kind || "text"),
|
||
description: String(item.description || ""),
|
||
source_code: String(item.source_code || ""),
|
||
source_name: String(item.source_name || ""),
|
||
column: String(item.column || ""),
|
||
row_index: Number(item.row_index || 0),
|
||
row_label: String(item.row_label || ""),
|
||
table_cell: Boolean(item.table_cell),
|
||
localized: Boolean(item.localized),
|
||
language: String(item.language || payload.language || ""),
|
||
rus_column: String(item.rus_column || ""),
|
||
eng_column: String(item.eng_column || ""),
|
||
resolved_column: String(item.resolved_column || ""),
|
||
language_explicit: Boolean(item.language_explicit),
|
||
})) : [];
|
||
const byKey = Object.fromEntries(items.map((item) => [item.key, item.value]));
|
||
const context = payload.context || mappingRequestContext();
|
||
const gameId = context.game_id || "";
|
||
const home = byKey["game.home.name"] || "Хозяева";
|
||
const away = byKey["game.away.name"] || "Гости";
|
||
return {
|
||
game: gameId ? { external_id: gameId } : {},
|
||
items,
|
||
gameLabel: gameId ? `${gameId} · ${home} — ${away}` : "Матч не выбран",
|
||
variables: Array.isArray(payload.variables) ? payload.variables : [],
|
||
sourceStatus: Array.isArray(payload.sources) ? payload.sources : [],
|
||
tables: Array.isArray(payload.tables) ? payload.tables : [],
|
||
error: payload.error || "",
|
||
};
|
||
}
|
||
|
||
function mappingSourceByKey(key) {
|
||
return mappingDataCatalog().items.find((item) => item.key === key) || null;
|
||
}
|
||
|
||
function mappingCurrentInput(profile) {
|
||
const inputs = mappingInventoryInputs(profile);
|
||
if (!inputs.length) return null;
|
||
let current = inputs.find((item) => mappingInputValue(item) === state.mappingSelectedInputKey);
|
||
if (!current) {
|
||
const linked = (profile?.fields || [])[0];
|
||
current = linked ? mappingInputByKey(profile, linked.vmix_input_key, linked.vmix_input_title) : null;
|
||
}
|
||
current ||= inputs[0];
|
||
state.mappingSelectedInputKey = mappingInputValue(current);
|
||
return current;
|
||
}
|
||
|
||
function mappingRowForField(profile, input, fieldName) {
|
||
return (profile?.fields || []).find((row) => {
|
||
const sameInput = String(row.vmix_input_key || "") === String(input?.key || "")
|
||
|| (!row.vmix_input_key && String(row.vmix_input_title || "") === String(input?.title || ""));
|
||
return sameInput && String(row.vmix_field || "") === String(fieldName || "");
|
||
}) || null;
|
||
}
|
||
|
||
function mappingSetLink(profile, input, field, source) {
|
||
const fields = [...(profile?.fields || [])];
|
||
const index = fields.findIndex((row) => {
|
||
const sameInput = String(row.vmix_input_key || "") === String(input?.key || "")
|
||
|| (!row.vmix_input_key && String(row.vmix_input_title || "") === String(input?.title || ""));
|
||
return sameInput && String(row.vmix_field || "") === String(field?.name || "");
|
||
});
|
||
const previous = index >= 0 ? fields[index] : null;
|
||
const previousRule = previous?.rule && typeof previous.rule === "object" ? { ...previous.rule } : {};
|
||
if (!previousRule.left_key || String(previousRule.left_key) === String(previous?.data_key || "")) {
|
||
previousRule.left_key = source.key;
|
||
}
|
||
const next = {
|
||
graphic: String(input?.title || "graphic").trim().slice(0, 100),
|
||
data_key: source.key,
|
||
vmix_input_key: String(input?.key || ""),
|
||
// Input number is positional in vMix and changes after reordering.
|
||
// Persist key/title only; number remains display-only in inventory.
|
||
vmix_input_number: "",
|
||
vmix_input_title: String(input?.title || ""),
|
||
vmix_field: String(field?.name || ""),
|
||
field_type: mappingFieldKind(field),
|
||
rule: previousRule,
|
||
enabled: true,
|
||
};
|
||
if (index >= 0) fields[index] = next;
|
||
else fields.push(next);
|
||
profile.fields = fields;
|
||
}
|
||
|
||
function mappingRemoveLink(profile, input, fieldName) {
|
||
profile.fields = (profile?.fields || []).filter((row) => {
|
||
const sameInput = String(row.vmix_input_key || "") === String(input?.key || "")
|
||
|| (!row.vmix_input_key && String(row.vmix_input_title || "") === String(input?.title || ""));
|
||
return !(sameInput && String(row.vmix_field || "") === String(fieldName || ""));
|
||
});
|
||
}
|
||
|
||
function mappingNormaliseRule(row, kind = "text") {
|
||
const raw = row?.rule && typeof row.rule === "object" ? row.rule : {};
|
||
const actionDefault = kind === "image" ? "visibility" : "text_colour";
|
||
return {
|
||
enabled: Boolean(raw.enabled),
|
||
action: String(raw.action || actionDefault),
|
||
left_key: String(raw.left_key || row?.data_key || ""),
|
||
operator: String(raw.operator || "gt"),
|
||
right_mode: String(raw.right_mode || "field"),
|
||
right_key: String(raw.right_key || ""),
|
||
right_value: raw.right_value == null ? "" : String(raw.right_value),
|
||
true_value: raw.true_value == null ? (actionDefault === "visibility" ? "on" : "#E5CEA8") : String(raw.true_value),
|
||
false_value: raw.false_value == null ? (actionDefault === "visibility" ? "off" : "#FFFFFF") : String(raw.false_value),
|
||
};
|
||
}
|
||
|
||
function mappingRuleSources(row, excludeKey = "") {
|
||
const items = (mappingDataCatalog().items || []).filter((item) => item.kind !== "image" && (!excludeKey || item.key !== excludeKey));
|
||
const current = mappingSourceByKey(row?.data_key);
|
||
const sameRow = current?.table_cell ? items.filter((item) => item.table_cell && item.source_code === current.source_code && Number(item.row_index) === Number(current.row_index)) : [];
|
||
const preferred = [...sameRow, ...items.filter((item) => !sameRow.includes(item) && !item.table_cell), ...items.filter((item) => !sameRow.includes(item) && item.table_cell)];
|
||
return preferred.slice(0, 600);
|
||
}
|
||
|
||
function mappingRuleNumeric(value) {
|
||
if (value == null || typeof value === "boolean") return null;
|
||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||
const text = String(value).trim();
|
||
if (!text) return null;
|
||
const compact = text.replaceAll(" ", "").replaceAll("%", "");
|
||
if (/^[-+]?\d{1,4}:\d{1,2}(?::\d{1,2}(?:[.,]\d+)?)?$/.test(compact)) {
|
||
const parts = compact.replace(",", ".").split(":").map(Number);
|
||
if (parts.every(Number.isFinite)) return parts.length === 2 ? parts[0] * 60 + parts[1] : parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||
}
|
||
const number = Number(compact.replace(",", "."));
|
||
return Number.isFinite(number) ? number : null;
|
||
}
|
||
|
||
function mappingRuleMatches(left, operator, right) {
|
||
const op = String(operator || "eq").toLowerCase();
|
||
const aText = left == null ? "" : String(left).trim();
|
||
const bText = right == null ? "" : String(right).trim();
|
||
if (["empty", "is_empty"].includes(op)) return aText === "";
|
||
if (["not_empty", "is_not_empty"].includes(op)) return aText !== "";
|
||
if (op === "contains") return aText.toLowerCase().includes(bText.toLowerCase());
|
||
if (op === "not_contains") return !aText.toLowerCase().includes(bText.toLowerCase());
|
||
const an = mappingRuleNumeric(left); const bn = mappingRuleNumeric(right);
|
||
const a = an != null && bn != null ? an : aText.toLowerCase();
|
||
const b = an != null && bn != null ? bn : bText.toLowerCase();
|
||
if (["gt", ">"].includes(op)) return a > b;
|
||
if (["lt", "<"].includes(op)) return a < b;
|
||
if (["gte", ">="].includes(op)) return a >= b;
|
||
if (["lte", "<="].includes(op)) return a <= b;
|
||
if (["neq", "!=", "<>"].includes(op)) return a !== b;
|
||
return a === b;
|
||
}
|
||
|
||
function mappingRuleCommand(row, field) {
|
||
const kind = mappingFieldKind(field);
|
||
const rule = mappingNormaliseRule(row, kind);
|
||
if (!rule.enabled || kind === "color") return null;
|
||
const left = mappingSourceByKey(rule.left_key || row.data_key);
|
||
if (!left) return null;
|
||
let right = null;
|
||
if (!["empty", "is_empty", "not_empty", "is_not_empty"].includes(rule.operator)) {
|
||
right = rule.right_mode === "value" ? { value: rule.right_value } : mappingSourceByKey(rule.right_key);
|
||
if (!right) return null;
|
||
}
|
||
const matched = mappingRuleMatches(left.value, rule.operator, right?.value);
|
||
if (rule.action === "visibility") {
|
||
const raw = matched ? rule.true_value : rule.false_value;
|
||
const visible = !["0", "false", "off", "hide", "hidden", "no"].includes(String(raw).toLowerCase());
|
||
return {
|
||
input: row.vmix_input_key || row.vmix_input_title || row.vmix_input_number,
|
||
selected_name: row.vmix_field,
|
||
value: "",
|
||
field_type: kind,
|
||
function: kind === "image" ? (visible ? "SetImageVisibleOn" : "SetImageVisibleOff") : (visible ? "SetTextVisibleOn" : "SetTextVisibleOff"),
|
||
data_key: `rule:${row.data_key}`,
|
||
};
|
||
}
|
||
if (kind !== "text") return null;
|
||
const colour = matched ? rule.true_value : rule.false_value;
|
||
if (!String(colour || "").trim()) return null;
|
||
return {
|
||
input: row.vmix_input_key || row.vmix_input_title || row.vmix_input_number,
|
||
selected_name: row.vmix_field,
|
||
value: String(colour),
|
||
field_type: kind,
|
||
function: "SetTextColour",
|
||
data_key: `rule:${row.data_key}`,
|
||
};
|
||
}
|
||
|
||
function mappingRuleEditor(row, field, input) {
|
||
const kind = mappingFieldKind(field);
|
||
if (!row || kind === "color") return "";
|
||
const rule = mappingNormaliseRule(row, kind);
|
||
const open = state.mappingRuleField === String(field.name || "");
|
||
const leftSources = mappingRuleSources(row);
|
||
const sources = mappingRuleSources(row, rule.left_key);
|
||
const leftExists = leftSources.some((item) => item.key === rule.left_key);
|
||
const rightExists = sources.some((item) => item.key === rule.right_key);
|
||
const actionOptions = kind === "image"
|
||
? `<option value="visibility" selected>Видимость</option>`
|
||
: `<option value="text_colour" ${rule.action === "text_colour" ? "selected" : ""}>Цвет текста</option><option value="visibility" ${rule.action === "visibility" ? "selected" : ""}>Видимость</option>`;
|
||
const leftOptions = `${rule.left_key && !leftExists ? `<option value="${escapeHtml(rule.left_key)}" selected>${escapeHtml(rule.left_key)}</option>` : ""}${leftSources.map((item) => `<option value="${escapeHtml(item.key)}" ${item.key === rule.left_key ? "selected" : ""}>${escapeHtml(item.label || item.key)} · ${escapeHtml(mappingDisplayValue(item.value, item.kind))}</option>`).join("")}`;
|
||
const sourceOptions = `${rule.right_key && !rightExists ? `<option value="${escapeHtml(rule.right_key)}" selected>${escapeHtml(rule.right_key)}</option>` : ""}${sources.map((item) => `<option value="${escapeHtml(item.key)}" ${item.key === rule.right_key ? "selected" : ""}>${escapeHtml(item.label || item.key)} · ${escapeHtml(mappingDisplayValue(item.value, item.kind))}</option>`).join("")}`;
|
||
const operatorOptions = [["gt", "> больше"], ["lt", "< меньше"], ["gte", "≥"], ["lte", "≤"], ["eq", "= равно"], ["neq", "≠ не равно"], ["contains", "содержит"], ["not_contains", "не содержит"], ["empty", "пусто"], ["not_empty", "не пусто"]]
|
||
.map(([value, label]) => `<option value="${value}" ${rule.operator === value ? "selected" : ""}>${label}</option>`).join("");
|
||
const summary = !rule.enabled ? "Правило выключено" : (rule.action === "visibility" ? `Visibility · ${rule.operator}` : `TextColour · ${rule.operator}`);
|
||
const valueEditor = rule.action === "visibility"
|
||
? `<div class="hockey-map-rule-values"><label>ЕСЛИ TRUE<select data-map-rule-true data-rule-field="${escapeHtml(field.name)}"><option value="on" ${String(rule.true_value).toLowerCase() === "on" ? "selected" : ""}>Показать</option><option value="off" ${String(rule.true_value).toLowerCase() === "off" ? "selected" : ""}>Скрыть</option></select></label><label>ELSE<select data-map-rule-false data-rule-field="${escapeHtml(field.name)}"><option value="off" ${String(rule.false_value).toLowerCase() === "off" ? "selected" : ""}>Скрыть</option><option value="on" ${String(rule.false_value).toLowerCase() === "on" ? "selected" : ""}>Показать</option></select></label></div>`
|
||
: `<div class="hockey-map-rule-values"><label>Цвет TRUE<input data-map-rule-true data-rule-field="${escapeHtml(field.name)}" value="${escapeHtml(rule.true_value)}" placeholder="#E5CEA8 или red"></label><label>Цвет ELSE<input data-map-rule-false data-rule-field="${escapeHtml(field.name)}" value="${escapeHtml(rule.false_value)}" placeholder="#FFFFFF"></label></div>`;
|
||
return `
|
||
<div class="hockey-map-rule-wrap ${rule.enabled ? "is-enabled" : ""}">
|
||
<button type="button" class="hockey-map-rule-toggle" data-map-rule-toggle="${escapeHtml(field.name)}"><span>⚡ Правило</span><small>${escapeHtml(summary)}</small><b>${open ? "−" : "+"}</b></button>
|
||
${open ? `<div class="hockey-map-rule-editor">
|
||
<div class="hockey-map-rule-head"><label><input type="checkbox" data-map-rule-enabled data-rule-field="${escapeHtml(field.name)}" ${rule.enabled ? "checked" : ""}> <span>Включить правило</span></label><button type="button" data-map-rule-clear="${escapeHtml(field.name)}">Сбросить</button></div>
|
||
<div class="hockey-map-rule-presets"><span>Быстро:</span><button type="button" data-map-rule-preset="gt" data-rule-field="${escapeHtml(field.name)}">Больше</button><button type="button" data-map-rule-preset="lt" data-rule-field="${escapeHtml(field.name)}">Меньше</button><button type="button" data-map-rule-preset="eq" data-rule-field="${escapeHtml(field.name)}">Равно</button></div>
|
||
<div class="hockey-map-rule-grid">
|
||
<label>Действие<select data-map-rule-action data-rule-field="${escapeHtml(field.name)}">${actionOptions}</select></label>
|
||
<label>Левое значение<select data-map-rule-left-key data-rule-field="${escapeHtml(field.name)}">${leftOptions}</select></label>
|
||
<label>Условие<select data-map-rule-operator data-rule-field="${escapeHtml(field.name)}">${operatorOptions}</select></label>
|
||
<label>Сравнивать с<select data-map-rule-right-mode data-rule-field="${escapeHtml(field.name)}"><option value="field" ${rule.right_mode === "field" ? "selected" : ""}>Другим полем</option><option value="value" ${rule.right_mode === "value" ? "selected" : ""}>Значением</option></select></label>
|
||
${rule.right_mode === "value" ? `<label class="is-wide">Значение<input data-map-rule-right-value data-rule-field="${escapeHtml(field.name)}" value="${escapeHtml(rule.right_value)}" placeholder="10"></label>` : `<label class="is-wide">Поле для сравнения<select data-map-rule-right-key data-rule-field="${escapeHtml(field.name)}"><option value="">— выберите поле —</option>${sourceOptions}</select></label>`}
|
||
</div>
|
||
${valueEditor}
|
||
<div class="hockey-map-rule-actions"><button type="button" data-map-rule-mirror="${escapeHtml(field.name)}" ${rule.right_mode === "field" && rule.right_key ? "" : "disabled"}>↔ Зеркально на второе поле</button>${mappingParseTableCellKey(row.data_key) && mappingFieldSequence(input, field.name) ? `<button type="button" data-map-rule-series="${escapeHtml(field.name)}">↧ Размножить правило по строкам</button>` : ""}</div>
|
||
</div>` : ""}
|
||
</div>`;
|
||
}
|
||
|
||
function mappingRuleRow(profile, input, fieldName) {
|
||
const row = mappingRowForField(profile, input, fieldName);
|
||
const field = (input?.fields || []).find((item) => String(item.name || "") === String(fieldName || ""));
|
||
return { row, field };
|
||
}
|
||
|
||
function mappingPatchRule(fieldName, patch) {
|
||
const profile = state.mappingActiveProfile; const input = mappingCurrentInput(profile);
|
||
const { row, field } = mappingRuleRow(profile, input, fieldName);
|
||
if (!row || !field) return null;
|
||
const base = mappingNormaliseRule(row, mappingFieldKind(field));
|
||
row.rule = { ...base, ...patch, left_key: Object.prototype.hasOwnProperty.call(patch, "left_key") ? String(patch.left_key || row.data_key) : (base.left_key || row.data_key) };
|
||
return row.rule;
|
||
}
|
||
|
||
function mappingMirrorRule(fieldName) {
|
||
const profile = state.mappingActiveProfile; const input = mappingCurrentInput(profile);
|
||
const { row, field } = mappingRuleRow(profile, input, fieldName);
|
||
if (!row || !field) return { ok: false, reason: "Связь не найдена." };
|
||
const rule = mappingNormaliseRule(row, mappingFieldKind(field));
|
||
if (rule.right_mode !== "field" || !rule.right_key) return { ok: false, reason: "Сначала выберите второе поле." };
|
||
const peer = (profile?.fields || []).find((item) => {
|
||
const sameInput = String(item.vmix_input_key || "") === String(input?.key || "") || (!item.vmix_input_key && String(item.vmix_input_title || "") === String(input?.title || ""));
|
||
return sameInput && String(item.data_key || "") === rule.right_key;
|
||
});
|
||
if (!peer) return { ok: false, reason: "Второе поле данных ещё не связано с элементом vMix этого Input." };
|
||
const invert = { gt: "lt", lt: "gt", gte: "lte", lte: "gte", ">": "<", "<": ">", ">=": "<=", "<=": ">=" };
|
||
peer.rule = { ...rule, left_key: peer.data_key, right_key: row.data_key, operator: invert[rule.operator] || rule.operator, enabled: true };
|
||
return { ok: true, target: peer.vmix_field };
|
||
}
|
||
|
||
function mappingCloneRuleSeries(fieldName) {
|
||
const profile = state.mappingActiveProfile; const input = mappingCurrentInput(profile);
|
||
const { row, field } = mappingRuleRow(profile, input, fieldName);
|
||
const anchorCell = mappingParseTableCellKey(row?.data_key);
|
||
const sequence = mappingFieldSequence(input, fieldName);
|
||
if (!row || !field || !anchorCell || !sequence) return { changed: 0 };
|
||
const rule = mappingNormaliseRule(row, mappingFieldKind(field));
|
||
const shiftKey = (key, delta) => {
|
||
const cell = mappingParseTableCellKey(key);
|
||
return cell && cell.sourceCode === anchorCell.sourceCode ? `${cell.sourceCode}.row.${cell.rowIndex + delta}.${cell.column}` : key;
|
||
};
|
||
let changed = 0;
|
||
for (const target of sequence.matches) {
|
||
const targetRow = mappingRowForField(profile, input, target.field.name);
|
||
if (!targetRow) continue;
|
||
const delta = target.index - sequence.anchorIndex;
|
||
targetRow.rule = { ...rule, left_key: shiftKey(rule.left_key || row.data_key, delta), right_key: rule.right_mode === "field" ? shiftKey(rule.right_key, delta) : rule.right_key };
|
||
changed += 1;
|
||
}
|
||
return { changed };
|
||
}
|
||
|
||
function mappingParseTableCellKey(key) {
|
||
const match = String(key || "").match(/^(.*)\.row\.(\d+)\.(.+)$/);
|
||
if (!match) return null;
|
||
return { sourceCode: match[1], rowIndex: Number(match[2] || 0), column: match[3] };
|
||
}
|
||
|
||
function mappingEscapeRegExp(value) {
|
||
return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
}
|
||
|
||
// Find the numeric token in a GT field name which forms the strongest sequence
|
||
// inside the current Input. Example: Player1.Text -> Player2.Text -> Player3.Text.
|
||
// If a name has several numbers, the token with the largest matching family wins.
|
||
function mappingFieldSequence(input, fieldName) {
|
||
const name = String(fieldName || "");
|
||
const tokens = [...name.matchAll(/\d+/g)];
|
||
let best = null;
|
||
for (const token of tokens) {
|
||
const start = Number(token.index || 0);
|
||
const finish = start + String(token[0] || "").length;
|
||
const prefix = name.slice(0, start);
|
||
const suffix = name.slice(finish);
|
||
const regex = new RegExp(`^${mappingEscapeRegExp(prefix)}(\\d+)${mappingEscapeRegExp(suffix)}$`);
|
||
const matches = (input?.fields || []).map((field) => {
|
||
const hit = String(field.name || "").match(regex);
|
||
return hit ? { field, index: Number(hit[1]) } : null;
|
||
}).filter(Boolean).sort((a, b) => a.index - b.index);
|
||
if (!best || matches.length > best.matches.length) {
|
||
best = { anchorIndex: Number(token[0]), prefix, suffix, matches };
|
||
}
|
||
}
|
||
return best && best.matches.length >= 2 ? best : null;
|
||
}
|
||
|
||
function mappingSeriesPlan(profile, input, anchorFieldName, mode = "column") {
|
||
const anchorRow = mappingRowForField(profile, input, anchorFieldName);
|
||
const anchorCell = anchorRow ? mappingParseTableCellKey(anchorRow.data_key) : null;
|
||
if (!anchorRow || !anchorCell) return { links: [], reason: "Связь должна вести на ячейку SQL-таблицы." };
|
||
|
||
let seedRows = [anchorRow];
|
||
if (mode === "row") {
|
||
seedRows = (profile?.fields || []).filter((row) => {
|
||
const sameInput = String(row.vmix_input_key || "") === String(input?.key || "")
|
||
|| (!row.vmix_input_key && String(row.vmix_input_title || "") === String(input?.title || ""));
|
||
if (!sameInput) return false;
|
||
const cell = mappingParseTableCellKey(row.data_key);
|
||
return cell && cell.sourceCode === anchorCell.sourceCode && cell.rowIndex === anchorCell.rowIndex;
|
||
});
|
||
}
|
||
|
||
const planned = new Map();
|
||
let sequentialSeeds = 0;
|
||
for (const seed of seedRows) {
|
||
const seedCell = mappingParseTableCellKey(seed.data_key);
|
||
const sequence = mappingFieldSequence(input, seed.vmix_field);
|
||
if (!seedCell || !sequence) continue;
|
||
sequentialSeeds += 1;
|
||
const seedField = (input?.fields || []).find((field) => String(field.name || "") === String(seed.vmix_field || ""));
|
||
const targetKind = seedField ? mappingFieldKind(seedField) : String(seed.field_type || "text");
|
||
for (const target of sequence.matches) {
|
||
const sourceRowIndex = seedCell.rowIndex + (target.index - sequence.anchorIndex);
|
||
if (sourceRowIndex < 1) continue;
|
||
const sourceKey = `${seedCell.sourceCode}.row.${sourceRowIndex}.${seedCell.column}`;
|
||
const source = mappingSourceByKey(sourceKey);
|
||
if (!source || !mappingCompatible(source, targetKind)) continue;
|
||
planned.set(String(target.field.name || ""), {
|
||
field: target.field,
|
||
source,
|
||
sourceRowIndex,
|
||
column: seedCell.column,
|
||
sourceCode: seedCell.sourceCode,
|
||
targetIndex: target.index,
|
||
});
|
||
}
|
||
}
|
||
const links = [...planned.values()].sort((a, b) => a.targetIndex - b.targetIndex || String(a.field.name).localeCompare(String(b.field.name), "ru", { numeric: true }));
|
||
if (!sequentialSeeds) return { links: [], reason: "В имени поля vMix не найдена числовая последовательность, например Name1.Text, Name2.Text…" };
|
||
if (links.length < 2) return { links: [], reason: "Не найдено следующих полей vMix или строк SQL для продолжения серии." };
|
||
return { links, seedCount: sequentialSeeds, anchorCell };
|
||
}
|
||
|
||
function mappingApplySeries(profile, input, anchorFieldName, mode = "column") {
|
||
const plan = mappingSeriesPlan(profile, input, anchorFieldName, mode);
|
||
if (!plan.links.length) return plan;
|
||
let changed = 0;
|
||
let overwritten = 0;
|
||
for (const item of plan.links) {
|
||
const existing = mappingRowForField(profile, input, item.field.name);
|
||
if (existing && String(existing.data_key || "") !== String(item.source.key || "")) overwritten += 1;
|
||
if (!existing || String(existing.data_key || "") !== String(item.source.key || "")) changed += 1;
|
||
mappingSetLink(profile, input, item.field, item.source);
|
||
}
|
||
return { ...plan, changed, overwritten };
|
||
}
|
||
|
||
function mappingCompatible(source, targetKind) {
|
||
if (!targetKind) return true;
|
||
if (targetKind === "image") return source.kind === "image";
|
||
return source.kind !== "image";
|
||
}
|
||
|
||
function mappingDataBrowser(profile, currentInput) {
|
||
const catalog = mappingDataCatalog();
|
||
const targetField = (currentInput?.fields || []).find((field) => String(field.name || "") === state.mappingTargetField) || null;
|
||
const targetKind = targetField ? mappingFieldKind(targetField) : "";
|
||
const query = String(state.mappingDataSearch || "").trim().toLowerCase();
|
||
const multiRowCodes = new Set((catalog.tables || []).filter((table) => Number(table.row_count || table.rows?.length || 0) > 1).map((table) => String(table.code || "")));
|
||
const categories = [];
|
||
for (const item of catalog.items) {
|
||
if (item.table_cell) continue;
|
||
// Raw *_RUS/*_ENG columns stay addressable for backwards compatibility, but
|
||
// the visual picker prefers one virtual AUTO language field.
|
||
if (item.language_explicit) continue;
|
||
// Multi-row SQL sources are configured through the table/cell picker below.
|
||
if (multiRowCodes.has(String(item.source_code || ""))) continue;
|
||
if (!mappingCompatible(item, targetKind)) continue;
|
||
const haystack = `${item.label} ${item.category} ${item.key} ${mappingDisplayValue(item.value, item.kind)}`.toLowerCase();
|
||
if (query && !haystack.includes(query)) continue;
|
||
let category = categories.find((row) => row.name === item.category);
|
||
if (!category) { category = { name: item.category, items: [] }; categories.push(category); }
|
||
category.items.push(item);
|
||
}
|
||
const selectedSource = state.mappingSelectedSourceKey;
|
||
const groups = categories.map((category) => `
|
||
<details class="hockey-map-data-group" data-map-data-group="${escapeHtml(category.name)}" ${query || state.mappingOpenDataGroups[category.name] ? "open" : ""}>
|
||
<summary><span>${escapeHtml(category.name)}</span><b>${category.items.length}</b></summary>
|
||
<div>${category.items.map((item) => `
|
||
<button type="button" class="hockey-map-data-item ${selectedSource === item.key ? "is-selected" : ""}" data-map-source="${escapeHtml(item.key)}">
|
||
<span class="hockey-map-data-main"><strong>${escapeHtml(item.label)}${item.localized ? ` <i class="hockey-map-lang-auto">AUTO ${String(item.language || "ru").toUpperCase()}</i>` : ""}</strong><em>${escapeHtml(mappingDisplayValue(item.value, item.kind))}</em></span>
|
||
<small>${escapeHtml(item.key)}${item.localized ? ` · ${escapeHtml(item.resolved_column || "")}` : ""}</small>
|
||
</button>`).join("")}</div>
|
||
</details>`).join("");
|
||
|
||
const tables = (catalog.tables || []).filter((table) => Number(table.row_count || table.rows?.length || 0) > 1);
|
||
if (tables.length && !tables.some((table) => String(table.code) === String(state.mappingSelectedTableSource))) {
|
||
state.mappingSelectedTableSource = String(tables[0].code || "");
|
||
}
|
||
const selectedTable = tables.find((table) => String(table.code) === String(state.mappingSelectedTableSource)) || null;
|
||
const selectedTableRows = selectedTable?.rows || [];
|
||
let selectedTableRow = selectedTableRows.find((row) => Number(row.index || 0) === Number(state.mappingSelectedTableRow || 0)) || selectedTableRows[0] || null;
|
||
if (selectedTableRow) state.mappingSelectedTableRow = Number(selectedTableRow.index || 1);
|
||
const quickCompatibleColumns = (selectedTable?.columns || []).filter((column) => {
|
||
if (!selectedTableRow) return false;
|
||
const key = `${selectedTable.code}.row.${selectedTableRow.index}.${column.key}`;
|
||
const source = mappingSourceByKey(key);
|
||
return source && (!targetKind || mappingCompatible(source, targetKind));
|
||
});
|
||
if (!quickCompatibleColumns.some((column) => String(column.key) === String(state.mappingSelectedTableColumn))) {
|
||
state.mappingSelectedTableColumn = String(quickCompatibleColumns[0]?.key || "");
|
||
}
|
||
const quickCellKey = selectedTable && selectedTableRow && state.mappingSelectedTableColumn
|
||
? `${selectedTable.code}.row.${selectedTableRow.index}.${state.mappingSelectedTableColumn}` : "";
|
||
const quickCellSource = quickCellKey ? mappingSourceByKey(quickCellKey) : null;
|
||
const quickLinkEnabled = Boolean(targetField && quickCellSource && mappingCompatible(quickCellSource, targetKind));
|
||
let tableMarkup = "";
|
||
let quickTableMarkup = "";
|
||
if (tables.length) {
|
||
quickTableMarkup = `
|
||
<details class="hockey-map-sql-cell-quick ${targetField ? "is-target-ready" : ""}" data-map-sql-cell-details ${state.mappingSqlCellOpen ? "open" : ""}>
|
||
<summary>
|
||
<div><span>SQL ЯЧЕЙКА</span><strong>Источник → строка → столбец</strong></div>
|
||
<small>${targetField ? `Для ${escapeHtml(currentInput?.title || "Input")} → ${escapeHtml(targetField.name || "")}` : "Сначала выберите поле vMix справа"}</small>
|
||
</summary>
|
||
<div class="hockey-map-sql-cell-grid">
|
||
<label><span>SQL источник</span><select data-map-quick-table-source>${tables.map((table) => `<option value="${escapeHtml(table.code)}" ${String(table.code) === String(selectedTable?.code) ? "selected" : ""}>${escapeHtml(table.name || table.code)}</option>`).join("")}</select></label>
|
||
<label><span>Строка</span><select data-map-quick-table-row ${selectedTableRows.length ? "" : "disabled"}>${selectedTableRows.map((row) => `<option value="${Number(row.index || 0)}" ${Number(row.index || 0) === Number(selectedTableRow?.index || 0) ? "selected" : ""}>#${Number(row.index || 0)} · ${escapeHtml(row.label || `Строка ${row.index}`)}</option>`).join("")}</select></label>
|
||
<label><span>Столбец</span><select data-map-quick-table-column ${quickCompatibleColumns.length ? "" : "disabled"}>${quickCompatibleColumns.map((column) => `<option value="${escapeHtml(column.key)}" ${String(column.key) === String(state.mappingSelectedTableColumn) ? "selected" : ""}>${escapeHtml(column.label || column.key)}${column.localized ? ` · AUTO ${String(column.language || selectedTable?.language || "ru").toUpperCase()}` : ""} · ${escapeHtml(column.key)}</option>`).join("")}</select></label>
|
||
</div>
|
||
<div class="hockey-map-sql-cell-preview">
|
||
<div><span>Текущее значение</span><strong>${escapeHtml(mappingDisplayValue(quickCellSource?.value, quickCellSource?.kind || "text"))}</strong><code>${escapeHtml(quickCellKey || "—")}</code></div>
|
||
<button type="button" data-map-quick-cell-link ${quickLinkEnabled ? "" : "disabled"}>${targetField ? `Связать с ${escapeHtml(targetField.name || "полем")}` : "Выберите поле vMix"}</button>
|
||
</div>
|
||
</details>`;
|
||
const rowSearch = String(state.mappingTableRowSearch || "").trim().toLowerCase();
|
||
const allRows = selectedTable?.rows || [];
|
||
const filteredRows = allRows.filter((row) => {
|
||
if (!rowSearch) return true;
|
||
const values = Object.values(row.values || {}).map((value) => mappingDisplayValue(value)).join(" ");
|
||
return `${row.index || ""} ${row.label || ""} ${values}`.toLowerCase().includes(rowSearch);
|
||
});
|
||
const visibleRows = filteredRows.slice(0, 120);
|
||
const columns = selectedTable?.columns || [];
|
||
const tableGridHeight = Math.min(430, Math.max(230, visibleRows.length * 44 + 42));
|
||
const cells = visibleRows.map((row) => `
|
||
<tr>
|
||
<th><b>${Number(row.index || 0)}</b><span>${escapeHtml(row.label || `Строка ${row.index}`)}</span></th>
|
||
${columns.map((column) => {
|
||
const key = `${selectedTable.code}.row.${row.index}.${column.key}`;
|
||
const source = mappingSourceByKey(key);
|
||
const compatible = source && mappingCompatible(source, targetKind);
|
||
return `<td><button type="button" data-map-source="${escapeHtml(key)}" class="${selectedSource === key ? "is-selected" : ""}" ${compatible ? "" : "disabled"} title="${escapeHtml(key)}"><strong>${escapeHtml(mappingDisplayValue(row.values?.[column.key], column.kind || "text"))}</strong><small>${escapeHtml(column.label || column.key)}</small></button></td>`;
|
||
}).join("")}
|
||
</tr>`).join("");
|
||
tableMarkup = `
|
||
<details class="hockey-map-table-picker" data-map-sql-table-details ${state.mappingSqlTableOpen ? "open" : ""}>
|
||
<summary><div><span>ТАБЛИЦА SQL</span><strong>Выберите конкретную строку и столбец</strong></div><b>${Number(selectedTable?.row_count || 0)} строк</b></summary>
|
||
<div class="hockey-map-table-controls">
|
||
<select data-map-table-source>${tables.map((table) => `<option value="${escapeHtml(table.code)}" ${String(table.code) === String(selectedTable?.code) ? "selected" : ""}>${escapeHtml(table.name || table.code)} · ${Number(table.row_count || 0)} строк</option>`).join("")}</select>
|
||
<label><span>⌕</span><input data-map-table-row-search value="${escapeHtml(state.mappingTableRowSearch)}" placeholder="Поиск строки по любому значению…"></label>
|
||
</div>
|
||
${selectedTable?.skipped ? `<div class="hockey-directory-empty">Не хватает параметров: ${escapeHtml((selectedTable.missing || []).map((x) => `:${x}`).join(", "))}</div>` : selectedTable?.error ? `<div class="hockey-directory-empty is-error">${escapeHtml(selectedTable.error)}</div>` : `
|
||
<div class="hockey-map-table-grid" style="height:${tableGridHeight}px"><table><thead><tr><th>Строка</th>${columns.map((column) => `<th><strong>${escapeHtml(column.label || column.key)}${column.localized ? ` <i class="hockey-map-lang-auto">AUTO ${String(column.language || selectedTable?.language || "ru").toUpperCase()}</i>` : ""}</strong><small>${escapeHtml(column.key)}</small></th>`).join("")}</tr></thead><tbody>${cells || `<tr><td colspan="${columns.length + 1}">Нет строк</td></tr>`}</tbody></table></div>
|
||
${filteredRows.length > visibleRows.length ? `<small class="hockey-map-table-limit">Показаны первые ${visibleRows.length} из ${filteredRows.length} строк. Используйте поиск строки.</small>` : ""}`}
|
||
</details>`;
|
||
}
|
||
|
||
const targetHint = targetField
|
||
? `<div class="hockey-map-target-hint is-active"><span>Сейчас настраиваем</span><strong>${escapeHtml(currentInput?.title || "Input")} → ${escapeHtml(targetField.name || "")}</strong><small>Можно выбрать обычное значение или ячейку SQL-таблицы</small></div>`
|
||
: `<div class="hockey-map-target-hint"><span>Как связать</span><strong>Выберите поле vMix справа</strong><small>Затем выберите значение или конкретную ячейку таблицы</small></div>`;
|
||
return `
|
||
<section class="hockey-map-data-browser">
|
||
<header><div><span>ДАННЫЕ МАТЧА</span><strong>${escapeHtml(catalog.gameLabel)}</strong></div><button type="button" data-map-live-refresh title="Обновить живые значения">↻</button></header>
|
||
${targetHint}
|
||
${quickTableMarkup}
|
||
${tableMarkup}
|
||
<label class="hockey-map-data-search"><span>⌕</span><input data-map-data-search value="${escapeHtml(state.mappingDataSearch)}" placeholder="Обычные значения: название, счёт, игрок, ключ…"></label>
|
||
<div class="hockey-map-data-scroll">${groups || `<div class="hockey-directory-empty">Подходящих одиночных данных не найдено.</div>`}</div>
|
||
</section>`;
|
||
}
|
||
|
||
function mappingVmixBrowser(profile, currentInput, testDevices) {
|
||
const allInputs = mappingInventoryInputs(profile);
|
||
const filteredInputs = mappingFilteredInputs(profile);
|
||
if (!currentInput) return `<div class="hockey-directory-empty">Agent не передал Inputs текущего vMix.</div>`;
|
||
const visibleInputs = filteredInputs.some((item) => mappingInputValue(item) === mappingInputValue(currentInput))
|
||
? filteredInputs
|
||
: [currentInput, ...filteredInputs];
|
||
const allFields = [...(currentInput.fields || [])].sort(mappingVmixFieldOrder);
|
||
const filter = ["text", "image", "color"].includes(state.mappingFieldFilter) ? state.mappingFieldFilter : "text";
|
||
const counts = {
|
||
text: allFields.filter((field) => mappingFieldFilterMatch(field, "text")).length,
|
||
image: allFields.filter((field) => mappingFieldFilterMatch(field, "image")).length,
|
||
color: allFields.filter((field) => mappingFieldFilterMatch(field, "color")).length,
|
||
};
|
||
const visibleFields = allFields.filter((field) => {
|
||
if (!mappingFieldFilterMatch(field, filter)) return false;
|
||
if (state.mappingHideLinked && mappingRowForField(profile, currentInput, field.name)) return false;
|
||
return true;
|
||
});
|
||
const rows = visibleFields.map((field) => {
|
||
const row = mappingRowForField(profile, currentInput, field.name);
|
||
const source = row ? mappingSourceByKey(row.data_key) : null;
|
||
const kind = mappingFieldKind(field);
|
||
const isTarget = String(state.mappingTargetField || "") === String(field.name || "");
|
||
const value = source ? mappingDisplayValue(source.value, source.kind) : "";
|
||
const cellInfo = source?.table_cell ? `<small class="hockey-map-cell-address">Строка ${Number(source.row_index || 0)} · ${escapeHtml(source.column || "")}</small>` : "";
|
||
const badge = kind === "image" ? "IMAGE" : kind === "color" ? "COLOR" : "TEXT";
|
||
return `
|
||
<article class="hockey-map-vmix-field ${row ? "is-linked" : ""} ${isTarget ? "is-target" : ""}" data-map-target="${escapeHtml(field.name || "")}">
|
||
<div class="hockey-map-vmix-field-head">
|
||
<div><strong>${escapeHtml(field.name || "Без названия")}</strong><span>${badge}</span></div>
|
||
${row ? `<button type="button" class="hockey-map-unlink" data-map-unlink="${escapeHtml(field.name || "")}" title="Удалить связь">×</button>` : ""}
|
||
</div>
|
||
${row ? `
|
||
<div class="hockey-map-link-value">
|
||
<span>${escapeHtml(source?.source_name || source?.category || "Источник")}</span>
|
||
<strong>${escapeHtml(source?.label || row.data_key)}</strong>
|
||
<em>${escapeHtml(value || "—")}</em>
|
||
${cellInfo}
|
||
<small>${escapeHtml(row.data_key)}</small>
|
||
</div>
|
||
<div class="hockey-map-field-actions">
|
||
<button type="button" data-map-choose="${escapeHtml(field.name || "")}">Изменить данные</button>
|
||
<button type="button" class="is-accent" data-map-test="${escapeHtml(field.name || "")}" ${testDevices.length ? "" : "disabled"}>Тест поля</button>
|
||
</div>
|
||
${mappingRuleEditor(row, field, currentInput)}
|
||
${source?.table_cell && mappingFieldSequence(currentInput, field.name) ? `<div class="hockey-map-series-actions">
|
||
<button type="button" data-map-series-column="${escapeHtml(field.name || "")}" title="Продолжить эту колонку по SQL-строкам и последовательным полям vMix">↧ Автосвязать столбец</button>
|
||
<button type="button" data-map-series-row="${escapeHtml(field.name || "")}" title="Размножить все уже настроенные поля этой SQL-строки на следующие строки">⇊ Размножить строку</button>
|
||
</div>` : ""}` : `
|
||
<button type="button" class="hockey-map-empty-link" data-map-choose="${escapeHtml(field.name || "")}">
|
||
<b>+</b><span>Выбрать данные</span><small>Обычное значение или конкретная ячейка SQL</small>
|
||
</button>`}
|
||
</article>`;
|
||
}).join("");
|
||
const linkedCount = allFields.filter((field) => mappingRowForField(profile, currentInput, field.name)).length;
|
||
return `
|
||
<section class="hockey-map-vmix-browser">
|
||
<header class="hockey-map-input-header">
|
||
<div class="hockey-map-input-choice">
|
||
<span>VMIX INPUT</span>
|
||
<label class="hockey-map-input-search"><span>⌕</span><input data-map-input-search value="${escapeHtml(state.mappingInputSearch)}" placeholder="Поиск по №, названию или key…"></label>
|
||
<select data-map-input-picker>${visibleInputs.map((item) => `<option value="${escapeHtml(mappingInputValue(item))}" ${mappingInputValue(item) === mappingInputValue(currentInput) ? "selected" : ""}>${escapeHtml(mappingInputLabel(item))}</option>`).join("")}</select>
|
||
<small>${visibleInputs.length === allInputs.length ? `${allInputs.length} Inputs` : `Найдено ${filteredInputs.length} из ${allInputs.length}`}</small>
|
||
<div class="hockey-map-field-toolbar">
|
||
<div class="hockey-map-field-tabs" role="group" aria-label="Тип поля vMix">
|
||
<button type="button" data-map-field-filter="text" class="${filter === "text" ? "is-active" : ""}">Text <b>${counts.text}</b></button>
|
||
<button type="button" data-map-field-filter="image" class="${filter === "image" ? "is-active" : ""}">Image <b>${counts.image}</b></button>
|
||
<button type="button" data-map-field-filter="color" class="${filter === "color" ? "is-active" : ""}">Color <b>${counts.color}</b></button>
|
||
</div>
|
||
<label class="hockey-map-hide-linked"><input type="checkbox" data-map-hide-linked ${state.mappingHideLinked ? "checked" : ""}><span>Скрывать связанные</span></label>
|
||
</div>
|
||
</div>
|
||
<div class="hockey-map-input-progress"><div><b>${linkedCount}</b><span>/ ${Number(allFields.length || 0)} полей</span></div><button type="button" data-map-apply-input ${linkedCount && testDevices.length ? "" : "disabled"}>Применить Input</button></div>
|
||
</header>
|
||
<div class="hockey-map-vmix-scroll">${rows || `<div class="hockey-directory-empty">${state.mappingHideLinked ? "В этой категории не осталось несвязанных полей." : "У этого Input нет полей выбранного типа."}</div>`}</div>
|
||
</section>`;
|
||
}
|
||
|
||
function mappingWorkspaceTabs() {
|
||
const tabs = [
|
||
["links", "Связи vMix"],
|
||
["context", "Переменные"],
|
||
["sql", "SQL источники"],
|
||
];
|
||
return `<nav class="hockey-map-workspace-tabs">${tabs.map(([key, label]) => `<button type="button" data-map-workspace-tab="${key}" class="${state.mappingWorkspaceTab === key ? "is-active" : ""}">${label}</button>`).join("")}</nav>`;
|
||
}
|
||
|
||
function mappingContextPanel() {
|
||
const variables = state.mappingCatalog?.variables || state.mappingContextVariables?.variables || [];
|
||
const rows = variables.map((item) => {
|
||
const system = Boolean(item.is_system || item.source_type === "system");
|
||
return `<article class="hockey-map-context-row ${system ? "is-system" : ""}" data-context-row="${Number(item.id || 0)}">
|
||
<div class="hockey-map-context-main">
|
||
<div><strong>${escapeHtml(item.label || item.key)}</strong><code>${escapeHtml(item.key)}</code></div>
|
||
<div class="hockey-map-context-badges"><span>${escapeHtml(item.entity_type || item.value_type || "text")}</span><span>${escapeHtml(item.scope || "match")}</span><span>${escapeHtml(item.source_type || "manual")}</span>${system ? `<b>SYSTEM</b>` : ""}</div>
|
||
</div>
|
||
<div class="hockey-map-context-live"><span>Сейчас</span><strong>${escapeHtml(mappingDisplayValue(item.value))}</strong></div>
|
||
${system ? `<div class="hockey-map-context-system-note">Системное значение задаётся программой автоматически.</div>` : `
|
||
<div class="hockey-map-context-edit-grid">
|
||
<label>Название<input data-context-label value="${escapeHtml(item.label || "")}"></label>
|
||
<label>Категория<input data-context-category value="${escapeHtml(item.category || "Пользовательские")}"></label>
|
||
<label>Entity<select data-context-entity>${["", "match", "team", "player", "coach", "referee", "goal", "penalty", "event", "device", "assignment"].map((v) => `<option value="${v}" ${String(item.entity_type || "") === v ? "selected" : ""}>${v || "—"}</option>`).join("")}</select></label>
|
||
<label>Scope<select data-context-scope>${["match", "session", "account", "global", "temporary"].map((v) => `<option value="${v}" ${String(item.scope || "match") === v ? "selected" : ""}>${v}</option>`).join("")}</select></label>
|
||
<label>Источник<select data-context-source>${["manual", "selection", "derived"].map((v) => `<option value="${v}" ${String(item.source_type || "manual") === v ? "selected" : ""}>${v}</option>`).join("")}</select></label>
|
||
<label>Текущее значение<input data-context-current value="${escapeHtml(item.value || "")}" placeholder="ID / значение"></label>
|
||
</div>
|
||
<div class="hockey-map-context-actions"><button type="button" data-context-save="${Number(item.id || 0)}">Сохранить описание</button><button type="button" class="is-accent" data-context-set="${escapeHtml(item.key)}">Установить значение</button><button type="button" class="danger" data-context-delete="${Number(item.id || 0)}">Удалить</button></div>`}
|
||
</article>`;
|
||
}).join("");
|
||
return `<section class="hockey-map-admin-panel">
|
||
<header class="hockey-map-admin-head"><div><span>CONTEXT VARIABLES</span><strong>Идентификаторы и выделенные сущности</strong><small>Эти значения можно использовать как <code>:параметр</code> в SQL. Match-scoped значения автоматически изолированы по матчу и аккаунту.</small></div></header>
|
||
<form class="hockey-map-context-create" data-context-create>
|
||
<label>Ключ<input name="key" required placeholder="my_special_player_id"></label>
|
||
<label>Название<input name="label" required placeholder="Мой выделенный игрок"></label>
|
||
<label>Entity<select name="entity_type"><option value="">—</option><option>match</option><option>team</option><option>player</option><option>coach</option><option>referee</option><option>goal</option><option>penalty</option><option>event</option></select></label>
|
||
<label>Scope<select name="scope"><option value="match">match</option><option value="session">session</option><option value="account">account</option><option value="global">global</option></select></label>
|
||
<label>Источник<select name="source_type"><option value="manual">manual</option><option value="selection">selection</option><option value="derived">derived</option></select></label>
|
||
<button type="submit" class="is-accent">+ Создать переменную</button>
|
||
</form>
|
||
<div class="hockey-map-context-list">${rows || `<div class="hockey-directory-empty">Переменных пока нет.</div>`}</div>
|
||
</section>`;
|
||
}
|
||
|
||
function mappingSqlHelp() {
|
||
const snippets = [
|
||
["Текст", "CONCAT_WS", "CONCAT_WS(' ', last_name, first_name)", "Склеить строки через разделитель"],
|
||
["Текст", "COALESCE", "COALESCE(value, '')", "Подставить значение вместо NULL"],
|
||
["Текст", "UPPER / LOWER", "UPPER(name)", "Верхний / нижний регистр"],
|
||
["Текст", "TRIM", "TRIM(name)", "Убрать пробелы по краям"],
|
||
["Текст", "REPLACE", "REPLACE(name, 'old', 'new')", "Заменить часть строки"],
|
||
["Текст", "SUBSTRING", "SUBSTRING(name FROM 1 FOR 12)", "Взять часть строки"],
|
||
["Условия", "CASE", "CASE WHEN score > 0 THEN 'Да' ELSE 'Нет' END", "Условное значение"],
|
||
["Условия", "NULLIF", "NULLIF(value, '')", "Преобразовать значение в NULL при совпадении"],
|
||
["Дата/время", "TO_CHAR", "TO_CHAR(game_date, 'DD.MM.YYYY')", "Форматировать дату/время"],
|
||
["Дата/время", "EXTRACT", "EXTRACT(YEAR FROM game_date)", "Получить год, месяц, день и т.п."],
|
||
["Дата/время", "CURRENT_DATE", "CURRENT_DATE", "Текущая дата PostgreSQL"],
|
||
["Дата/время", "Текущее время", "TO_CHAR(NOW(), 'HH24:MI:SS')", "Текущее время PostgreSQL до секунд"],
|
||
["Дата/время", "Часовой пояс", "TO_CHAR(NOW() AT TIME ZONE 'Europe/Moscow', 'HH24:MI:SS')", "Время в выбранной IANA-зоне"],
|
||
["Числа", "ROUND", "ROUND(value::numeric, 2)", "Округлить число"],
|
||
["Числа", "ABS", "ABS(value)", "Модуль числа"],
|
||
["Числа", "GREATEST / LEAST", "GREATEST(a, b)", "Максимум / минимум из значений"],
|
||
["Типы", "::text", "value::text", "Быстро привести значение к типу"],
|
||
["Типы", "CAST", "CAST(value AS integer)", "Стандартное преобразование типа"],
|
||
["Агрегация", "COUNT", "COUNT(*)", "Количество строк"],
|
||
["Агрегация", "SUM / AVG", "AVG(value)", "Сумма / среднее"],
|
||
["Агрегация", "STRING_AGG", "STRING_AGG(name, ', ' ORDER BY name)", "Собрать строки в одну"],
|
||
["Таблицы", "ROW_NUMBER", "ROW_NUMBER() OVER (ORDER BY points DESC)", "Нумерация строк без схлопывания таблицы"],
|
||
];
|
||
const groups = [...new Set(snippets.map((row) => row[0]))];
|
||
return `<details class="hockey-map-sql-help">
|
||
<summary><span>?</span><div><strong>Подсказки PostgreSQL</strong><small>Функции, форматы и примеры — клик вставляет пример в SQL</small></div></summary>
|
||
<div class="hockey-map-sql-help-body">
|
||
<div class="hockey-map-sql-help-note"><b>Наш редактор:</b> один read-only <code>SELECT</code> или <code>WITH … SELECT</code>. Завершающий <code>;</code> и SQL-комментарии можно писать. Параметры проекта имеют вид <code>:game_id</code>; двоеточия внутри строк PostgreSQL, например <code>'HH24:MI:SS'</code>, переменными не считаются.</div>
|
||
<div class="hockey-map-sql-help-lang"><b>Автовыбор языка:</b> если запрос возвращает пару <code>name_RUS</code> + <code>name_ENG</code>, Mapping создаёт одно виртуальное поле <code>name</code> и сам выбирает нужную колонку по языку интерфейса.</div>
|
||
${groups.map((group) => `<section><h5>${escapeHtml(group)}</h5><div>${snippets.filter((row) => row[0] === group).map((row) => `<button type="button" data-sql-snippet="${escapeHtml(row[2])}" title="${escapeHtml(row[3])}"><strong>${escapeHtml(row[1])}</strong><code>${escapeHtml(row[2])}</code><small>${escapeHtml(row[3])}</small></button>`).join("")}</div></section>`).join("")}
|
||
</div>
|
||
</details>`;
|
||
}
|
||
|
||
function mappingSqlPanel() {
|
||
const sources = state.mappingSqlSources?.sources || [];
|
||
const selected = state.mappingSelectedSqlSourceId === "new" ? null : sources.find((item) => Number(item.id) === Number(state.mappingSelectedSqlSourceId)) || sources[0] || null;
|
||
if (selected && !state.mappingSelectedSqlSourceId) state.mappingSelectedSqlSourceId = selected.id;
|
||
const sourceToken = String(state.mappingSelectedSqlSourceId || selected?.id || "new");
|
||
const liveDraft = state.mappingSqlDraft && String(state.mappingSqlDraft.__source_id || "") === sourceToken ? state.mappingSqlDraft : null;
|
||
const draft = liveDraft || selected || { id: "", code: "", name: "", category: "Данные", description: "", sql_text: "SELECT\n :game_id AS \"game_id\"", enabled: true, auto_refresh_enabled: false, refresh_interval_ms: 1000, sort_order: 1000, field_metadata: {} };
|
||
const preview = state.mappingSqlPreview;
|
||
const variableChips = (state.mappingCatalog?.variables || []).map((item) => `<button type="button" data-sql-param="${escapeHtml(item.key)}" title="${escapeHtml(item.label || item.key)}"><code>:${escapeHtml(item.key)}</code><span>${escapeHtml(mappingDisplayValue(item.value))}</span></button>`).join("");
|
||
const list = [`<button type="button" data-sql-source="new" class="${state.mappingSelectedSqlSourceId === "new" ? "is-active" : ""}"><strong>+ Новый источник</strong><small>Создать SELECT</small></button>`, ...sources.map((item) => `<button type="button" data-sql-source="${item.id}" class="${Number(draft.id) === Number(item.id) && state.mappingSelectedSqlSourceId !== "new" ? "is-active" : ""}"><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.code)} · ${item.enabled ? "ON" : "OFF"}${item.auto_refresh_enabled ? ` · AUTO ${Math.max(1, Number(item.refresh_interval_ms || 1000) / 1000)}с` : ""}</small></button>`)].join("");
|
||
let previewMarkup = `<div class="hockey-directory-empty">Нажмите «Проверить SQL», чтобы увидеть реальные колонки и строки текущего матча.</div>`;
|
||
if (preview) {
|
||
if (preview.error) previewMarkup = `<div class="hockey-directory-empty is-error">${escapeHtml(preview.error)}</div>`;
|
||
else if (preview.skipped) previewMarkup = `<div class="hockey-directory-empty">Не хватает переменных: ${escapeHtml((preview.missing || []).map((x) => `:${x}`).join(", "))}</div>`;
|
||
else {
|
||
const columns = preview.columns || [];
|
||
previewMarkup = `<div class="hockey-map-sql-preview-meta">${Number(preview.row_count || 0)} строк · ${columns.length} колонок</div><div class="hockey-map-sql-preview-table"><table><thead><tr>${columns.map((c) => `<th>${escapeHtml(c)}</th>`).join("")}</tr></thead><tbody>${(preview.rows || []).map((row) => `<tr>${columns.map((c) => `<td>${escapeHtml(mappingDisplayValue(row[c]))}</td>`).join("")}</tr>`).join("")}</tbody></table></div>`;
|
||
}
|
||
}
|
||
return `<section class="hockey-map-admin-panel is-sql">
|
||
<header class="hockey-map-admin-head"><div><span>SQL DATA SOURCES</span><strong>Динамические ключи из PostgreSQL</strong><small>Read-only PostgreSQL SELECT. Колонки результата автоматически становятся ключами вида <code>source.column</code>. Пары <code>_RUS/_ENG</code> объединяются в языковой AUTO-ключ.</small></div></header>
|
||
<div class="hockey-map-sql-layout">
|
||
<aside class="hockey-map-sql-list">${list}</aside>
|
||
<div class="hockey-map-sql-editor" data-sql-editor data-source-id="${escapeHtml(draft.id || "new")}">
|
||
<div class="hockey-map-sql-form-grid">
|
||
<label>Код источника<input data-sql-code value="${escapeHtml(draft.code || "")}" placeholder="selected_player"></label>
|
||
<label>Название<input data-sql-name value="${escapeHtml(draft.name || "")}" placeholder="Выбранный игрок"></label>
|
||
<label>Категория<input data-sql-category value="${escapeHtml(draft.category || "Данные")}"></label>
|
||
<label>Порядок<input type="number" data-sql-order value="${Number(draft.sort_order || 1000)}"></label>
|
||
</div>
|
||
<label class="hockey-map-sql-description">Описание<input data-sql-description value="${escapeHtml(draft.description || "")}"></label>
|
||
<div class="hockey-map-sql-refresh ${draft.auto_refresh_enabled ? "is-enabled" : ""}">
|
||
<label class="hockey-directory-check"><input type="checkbox" data-sql-auto-refresh ${draft.auto_refresh_enabled ? "checked" : ""}><span>Автообновление в vMix</span></label>
|
||
<label>Период, сек<input type="number" min="1" max="3600" step="1" data-sql-refresh-seconds value="${Math.max(1, Math.round(Number(draft.refresh_interval_ms || 1000) / 1000))}" ${draft.auto_refresh_enabled ? "" : "disabled"}></label>
|
||
<small>Сервер повторяет только этот SQL. В vMix отправляются только связанные с ним поля и только если значение реально изменилось.</small>
|
||
</div>
|
||
<div class="hockey-map-sql-params"><span>Доступные параметры — клик вставляет в SQL</span><div>${variableChips}</div></div>
|
||
${mappingSqlHelp()}
|
||
<label class="hockey-map-sql-code"><span>SQL</span><textarea data-sql-text spellcheck="false">${escapeHtml(draft.sql_text || "")}</textarea></label>
|
||
<div class="hockey-map-sql-actions"><label class="hockey-directory-check"><input type="checkbox" data-sql-enabled ${draft.enabled !== false ? "checked" : ""}><span>Источник включён</span></label><button type="button" data-sql-preview>Проверить SQL</button><button type="button" class="is-accent" data-sql-save>${draft.id ? "Сохранить источник" : "Создать источник"}</button>${draft.id ? `<button type="button" class="danger" data-sql-delete>Удалить</button>` : ""}</div>
|
||
<section class="hockey-map-sql-preview"><h4>Живой результат</h4>${previewMarkup}</section>
|
||
</div>
|
||
</div>
|
||
</section>`;
|
||
}
|
||
|
||
function renderMapping() {
|
||
const modal = ensureModal();
|
||
const devices = state.mappingDevices?.devices || [];
|
||
const profiles = state.mappingProfiles?.profiles || [];
|
||
const profile = state.mappingActiveProfile;
|
||
const usableDevices = devices.filter((item) => item.project_fingerprint);
|
||
const deviceRows = devices.length ? devices.map((item) => `
|
||
<article class="hockey-mapping-device ${item.online ? "is-online" : ""}">
|
||
<div><strong>${escapeHtml(item.name || item.device_id)}</strong><code>${escapeHtml(item.device_id)}</code></div>
|
||
<span>${item.vmix_connected ? "vMix подключён" : "vMix не найден"}</span>
|
||
<span>${Number(item.input_count || 0)} Inputs · ${Number(item.field_count || 0)} полей</span>
|
||
<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 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}" title="Открыть и редактировать конфиг">
|
||
<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>
|
||
<div class="hockey-map-profile-actions">
|
||
<button type="button" class="hockey-map-profile-icon hockey-map-use-profile ${isUsed ? "is-used" : ""}" data-map-use-profile="${item.id}" title="${isUsed ? "Этот конфиг уже используется на моём Agent" : "Применить к моему Agent"}" aria-label="${isUsed ? "Используется на моём Agent" : "Применить к моему Agent"}">${isUsed ? "✓" : "▶"}</button>
|
||
<button type="button" class="hockey-map-profile-icon hockey-map-copy-profile" data-map-copy-profile="${item.id}" title="Копировать конфиг" aria-label="Копировать конфиг">⧉</button>
|
||
</div>
|
||
</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>` : "";
|
||
|
||
let editor = "";
|
||
if (state.mappingWorkspaceTab === "context") {
|
||
editor = mappingContextPanel();
|
||
} else if (state.mappingWorkspaceTab === "sql") {
|
||
editor = mappingSqlPanel();
|
||
} else if (!profile) {
|
||
editor = `<div class="hockey-directory-empty">Выберите профиль слева или создайте новый из подключённого vMix.</div>`;
|
||
} else {
|
||
const currentInput = mappingCurrentInput(profile);
|
||
const matchingDevices = devices.filter((item) => item.project_fingerprint && item.project_fingerprint === profile.project_fingerprint && item.online && item.vmix_connected);
|
||
if (!state.mappingTestDeviceId || !matchingDevices.some((item) => item.device_id === state.mappingTestDeviceId)) state.mappingTestDeviceId = matchingDevices[0]?.device_id || "";
|
||
const catalog = mappingDataCatalog();
|
||
const gameReady = Boolean(catalog.game && (catalog.game.external_id || catalog.game.id));
|
||
editor = `
|
||
<div class="hockey-mapping-editor-head is-visual">
|
||
<div>
|
||
<label>Название<input data-mapping-name value="${escapeHtml(profile.name || "")}"></label>
|
||
<label>Описание<input data-mapping-description value="${escapeHtml(profile.description || "")}"></label>
|
||
</div>
|
||
<div class="hockey-mapping-meta">
|
||
<span>Версия <b>${Number(profile.version || 1)}</b></span>
|
||
<span>Inputs <b>${Number(profile.inventory?.input_count || mappingInventoryInputs(profile).length)}</b></span>
|
||
<span>Связей <b>${Number(profile.fields?.length || 0)}</b></span>
|
||
</div>
|
||
</div>
|
||
<div class="hockey-map-livebar ${gameReady ? "is-ready" : ""}">
|
||
<div><span>${gameReady ? "● ЖИВОЙ ПРИМЕР" : "○ НЕТ ТЕСТОВОГО МАТЧА"}</span><strong>${escapeHtml(catalog.gameLabel)}</strong><small>${gameReady ? "Значения получены через SQL Data Sources из PostgreSQL." : "Выберите матч; системный context game_id заполнится автоматически."}</small></div>
|
||
<label>Тестовый Agent<select data-map-test-device><option value="">— нет подходящего online Agent —</option>${matchingDevices.map((item) => `<option value="${escapeHtml(item.device_id)}" ${item.device_id === state.mappingTestDeviceId ? "selected" : ""}>${escapeHtml(item.name || item.device_id)}</option>`).join("")}</select></label>
|
||
</div>
|
||
<div class="hockey-map-visual-workspace">
|
||
${mappingDataBrowser(profile, currentInput)}
|
||
${mappingVmixBrowser(profile, currentInput, matchingDevices)}
|
||
</div>
|
||
<div class="hockey-mapping-savebar is-visual">
|
||
<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>
|
||
<button type="button" class="danger" data-map-delete>Удалить конфиг</button>
|
||
</div>
|
||
<div><button type="button" class="is-accent" data-map-save>Сохранить конфиг</button></div>
|
||
</div>`;
|
||
}
|
||
|
||
modal.innerHTML = shellMarkup(`
|
||
<div class="hockey-mapping-layout is-visual">
|
||
<aside class="hockey-mapping-sidebar">
|
||
<h3>vMix проекты</h3>
|
||
<div class="hockey-mapping-devices">${deviceRows}</div>
|
||
<h3>Конфиги Mapping</h3>
|
||
<div class="hockey-map-config-hint">▶ применяет готовый конфиг к вашему Agent. ⧉ создаёт независимую копию со всеми связями — её можно переименовать, пересчитать под другой vMix и отредактировать.</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>`);
|
||
bindShell();
|
||
bindMapping();
|
||
}
|
||
|
||
function collectMappingFields() {
|
||
return [...(state.mappingActiveProfile?.fields || [])].map((row, index) => ({
|
||
...row,
|
||
// Never re-save a positional vMix Input number when a stable key/title is known.
|
||
vmix_input_number: (row.vmix_input_key || row.vmix_input_title) ? "" : String(row.vmix_input_number || ""),
|
||
sort_order: index,
|
||
})).filter((row) => row.data_key && row.vmix_field);
|
||
}
|
||
|
||
function bindMapping() {
|
||
const modal = ensureModal();
|
||
|
||
// Keep the vMix field list exactly where the operator left it across
|
||
// renderMapping() calls (rule edits, save/reload, test/apply actions).
|
||
const vmixScroll = modal.querySelector(".hockey-map-vmix-scroll");
|
||
if (vmixScroll) {
|
||
vmixScroll.scrollTop = Math.max(0, Number(state.mappingVmixScrollTop || 0));
|
||
vmixScroll.addEventListener("scroll", () => {
|
||
state.mappingVmixScrollTop = vmixScroll.scrollTop;
|
||
}, { passive: true });
|
||
}
|
||
|
||
modal.querySelectorAll("[data-map-workspace-tab]").forEach((button) => button.addEventListener("click", () => {
|
||
state.mappingWorkspaceTab = button.dataset.mapWorkspaceTab || "links";
|
||
state.section = state.mappingWorkspaceTab === "context" ? "mapping_context" : (state.mappingWorkspaceTab === "sql" ? "mapping_sql" : "mapping");
|
||
state.mappingSqlPreview = null;
|
||
setStatus("");
|
||
renderMapping();
|
||
}));
|
||
|
||
modal.querySelector("[data-context-create]")?.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
const form = new FormData(event.currentTarget);
|
||
try {
|
||
await request("/api/hockey/admin/mapping-context/variables", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
key: form.get("key"), label: form.get("label"), category: "Пользовательские", description: "",
|
||
value_type: "id", entity_type: form.get("entity_type") || "", scope: form.get("scope") || "match",
|
||
source_type: form.get("source_type") || "manual", default_value: "", enabled: true, sort_order: 1000,
|
||
}),
|
||
});
|
||
await loadMapping(state.mappingActiveProfile?.id || null);
|
||
setStatus("Переменная создана.");
|
||
} catch (error) { setStatus(error.message, true); }
|
||
renderMapping();
|
||
});
|
||
|
||
modal.querySelectorAll("[data-context-save]").forEach((button) => button.addEventListener("click", async () => {
|
||
const id = Number(button.dataset.contextSave || 0);
|
||
const item = (state.mappingCatalog?.variables || state.mappingContextVariables?.variables || []).find((row) => Number(row.id) === id);
|
||
const row = button.closest("[data-context-row]");
|
||
if (!item || !row) return;
|
||
try {
|
||
await request(`/api/hockey/admin/mapping-context/variables/${id}`, {
|
||
method: "PUT",
|
||
body: JSON.stringify({
|
||
key: item.key,
|
||
label: row.querySelector("[data-context-label]")?.value || item.label,
|
||
category: row.querySelector("[data-context-category]")?.value || item.category || "Пользовательские",
|
||
description: item.description || "", value_type: item.value_type || "id",
|
||
entity_type: row.querySelector("[data-context-entity]")?.value || "",
|
||
scope: row.querySelector("[data-context-scope]")?.value || "match",
|
||
source_type: row.querySelector("[data-context-source]")?.value || "manual",
|
||
default_value: item.default_value || "", enabled: item.enabled !== false, sort_order: Number(item.sort_order || 1000),
|
||
}),
|
||
});
|
||
await loadMapping(state.mappingActiveProfile?.id || null); setStatus(`Переменная ${item.key} сохранена.`);
|
||
} catch (error) { setStatus(error.message, true); }
|
||
renderMapping();
|
||
}));
|
||
|
||
modal.querySelectorAll("[data-context-set]").forEach((button) => button.addEventListener("click", async () => {
|
||
const key = button.dataset.contextSet || "";
|
||
const row = button.closest("[data-context-row]");
|
||
const value = row?.querySelector("[data-context-current]")?.value || "";
|
||
try {
|
||
await request(`/api/hockey/context/${encodeURIComponent(key)}`, { method: "POST", body: JSON.stringify({ value, context: mappingRequestContext() }) });
|
||
await loadMappingCatalog(); setStatus(`${key} = ${value || "—"}`);
|
||
} catch (error) { setStatus(error.message, true); }
|
||
renderMapping();
|
||
}));
|
||
|
||
modal.querySelectorAll("[data-context-delete]").forEach((button) => button.addEventListener("click", async () => {
|
||
const id = Number(button.dataset.contextDelete || 0);
|
||
const item = (state.mappingCatalog?.variables || []).find((row) => Number(row.id) === id);
|
||
if (!id || !window.confirm(`Удалить переменную «${item?.label || item?.key || id}»?`)) return;
|
||
try {
|
||
await request(`/api/hockey/admin/mapping-context/variables/${id}`, { method: "DELETE" });
|
||
await loadMapping(state.mappingActiveProfile?.id || null); setStatus("Переменная удалена.");
|
||
} catch (error) { setStatus(error.message, true); }
|
||
renderMapping();
|
||
}));
|
||
|
||
modal.querySelectorAll("[data-sql-source]").forEach((button) => button.addEventListener("click", () => {
|
||
state.mappingSelectedSqlSourceId = button.dataset.sqlSource === "new" ? "new" : Number(button.dataset.sqlSource);
|
||
state.mappingSqlPreview = null;
|
||
state.mappingSqlDraft = null;
|
||
renderMapping();
|
||
}));
|
||
|
||
modal.querySelectorAll("[data-sql-param]").forEach((button) => button.addEventListener("click", () => {
|
||
const textarea = modal.querySelector("[data-sql-text]");
|
||
if (!textarea) return;
|
||
const token = `:${button.dataset.sqlParam}`;
|
||
const start = textarea.selectionStart ?? textarea.value.length;
|
||
const end = textarea.selectionEnd ?? start;
|
||
textarea.value = textarea.value.slice(0, start) + token + textarea.value.slice(end);
|
||
textarea.focus(); textarea.setSelectionRange(start + token.length, start + token.length);
|
||
}));
|
||
|
||
modal.querySelectorAll("[data-sql-snippet]").forEach((button) => button.addEventListener("click", () => {
|
||
const textarea = modal.querySelector("[data-sql-text]");
|
||
if (!textarea) return;
|
||
const snippet = String(button.dataset.sqlSnippet || "");
|
||
const start = textarea.selectionStart ?? textarea.value.length;
|
||
const end = textarea.selectionEnd ?? start;
|
||
textarea.value = textarea.value.slice(0, start) + snippet + textarea.value.slice(end);
|
||
textarea.focus(); textarea.setSelectionRange(start + snippet.length, start + snippet.length);
|
||
state.mappingSqlDraft = null;
|
||
}));
|
||
|
||
modal.querySelector("[data-sql-auto-refresh]")?.addEventListener("change", (event) => {
|
||
const seconds = modal.querySelector("[data-sql-refresh-seconds]");
|
||
if (seconds) seconds.disabled = !event.currentTarget.checked;
|
||
event.currentTarget.closest(".hockey-map-sql-refresh")?.classList.toggle("is-enabled", Boolean(event.currentTarget.checked));
|
||
});
|
||
|
||
const sqlPayload = () => {
|
||
const selected = (state.mappingSqlSources?.sources || []).find((item) => Number(item.id) === Number(state.mappingSelectedSqlSourceId));
|
||
return {
|
||
code: modal.querySelector("[data-sql-code]")?.value || "",
|
||
name: modal.querySelector("[data-sql-name]")?.value || "",
|
||
category: modal.querySelector("[data-sql-category]")?.value || "Данные",
|
||
description: modal.querySelector("[data-sql-description]")?.value || "",
|
||
sql_text: modal.querySelector("[data-sql-text]")?.value || "",
|
||
field_metadata: selected?.field_metadata || {},
|
||
enabled: Boolean(modal.querySelector("[data-sql-enabled]")?.checked),
|
||
auto_refresh_enabled: Boolean(modal.querySelector("[data-sql-auto-refresh]")?.checked),
|
||
refresh_interval_ms: Math.max(1000, Math.min(3600000, Number(modal.querySelector("[data-sql-refresh-seconds]")?.value || 1) * 1000)),
|
||
sort_order: Number(modal.querySelector("[data-sql-order]")?.value || 1000),
|
||
};
|
||
};
|
||
|
||
modal.querySelector("[data-sql-preview]")?.addEventListener("click", async () => {
|
||
const payload = sqlPayload();
|
||
const selected = (state.mappingSqlSources?.sources || []).find((item) => Number(item.id) === Number(state.mappingSelectedSqlSourceId));
|
||
// Preview must never reset an unsaved editor. Keep the complete form draft
|
||
// in UI state before the async request and restore it on render.
|
||
state.mappingSqlDraft = {
|
||
...payload,
|
||
id: selected?.id || "",
|
||
field_metadata: selected?.field_metadata || payload.field_metadata || {},
|
||
__source_id: String(state.mappingSelectedSqlSourceId || selected?.id || "new"),
|
||
};
|
||
try {
|
||
state.mappingSqlPreview = await request("/api/hockey/admin/mapping-data/preview-sql", { method: "POST", body: JSON.stringify({ sql_text: payload.sql_text, context: mappingRequestContext() }) });
|
||
setStatus(state.mappingSqlPreview.skipped ? "SQL корректен, но не хватает параметров." : "SQL выполнен в read-only режиме.");
|
||
} catch (error) { state.mappingSqlPreview = { error: error.message }; setStatus(error.message, true); }
|
||
renderMapping();
|
||
});
|
||
|
||
modal.querySelector("[data-sql-save]")?.addEventListener("click", async () => {
|
||
try {
|
||
const payload = sqlPayload();
|
||
const id = state.mappingSelectedSqlSourceId;
|
||
const result = id && id !== "new"
|
||
? await request(`/api/hockey/admin/mapping-data/sources/${id}`, { method: "PUT", body: JSON.stringify(payload) })
|
||
: await request("/api/hockey/admin/mapping-data/sources", { method: "POST", body: JSON.stringify(payload) });
|
||
state.mappingSelectedSqlSourceId = result.id;
|
||
state.mappingSqlPreview = null;
|
||
state.mappingSqlDraft = null;
|
||
await loadMapping(state.mappingActiveProfile?.id || null);
|
||
setStatus(`SQL-источник «${result.name}» сохранён. Ключи обновлены автоматически.`);
|
||
} catch (error) { setStatus(error.message, true); }
|
||
renderMapping();
|
||
});
|
||
|
||
modal.querySelector("[data-sql-delete]")?.addEventListener("click", async () => {
|
||
const id = Number(state.mappingSelectedSqlSourceId || 0);
|
||
const item = (state.mappingSqlSources?.sources || []).find((row) => Number(row.id) === id);
|
||
if (!id || !window.confirm(`Удалить SQL-источник «${item?.name || id}»?`)) return;
|
||
try {
|
||
await request(`/api/hockey/admin/mapping-data/sources/${id}`, { method: "DELETE" });
|
||
state.mappingSelectedSqlSourceId = null; state.mappingSqlPreview = null; state.mappingSqlDraft = null;
|
||
await loadMapping(state.mappingActiveProfile?.id || null); setStatus("SQL-источник удалён.");
|
||
} catch (error) { setStatus(error.message, true); }
|
||
renderMapping();
|
||
});
|
||
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-map-copy-profile]").forEach((button) => button.addEventListener("click", async (event) => {
|
||
event.stopPropagation();
|
||
const profileId = Number(button.dataset.mapCopyProfile || 0);
|
||
if (!profileId) return;
|
||
const source = (state.mappingProfiles?.profiles || []).find((item) => Number(item.id) === profileId);
|
||
button.disabled = true;
|
||
button.textContent = "…";
|
||
try {
|
||
const created = await request(`/api/hockey/admin/vmix-mapping/profiles/${profileId}/duplicate`, { method: "POST" });
|
||
state.mappingSelectedInputKey = "";
|
||
state.mappingTargetField = "";
|
||
state.mappingSelectedSourceKey = "";
|
||
state.mappingVmixScrollTop = 0;
|
||
await loadMapping(created.id);
|
||
setStatus(`Копия «${created.name}» создана · ${Number(created.copied_fields || created.fields?.length || 0)} связей. Можно переименовать, считать структуру другого vMix и редактировать.`);
|
||
} catch (error) {
|
||
setStatus(error.message || `Не удалось скопировать конфиг «${source?.name || profileId}»`, 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}`);
|
||
state.mappingSelectedInputKey = "";
|
||
state.mappingTargetField = "";
|
||
state.mappingSelectedSourceKey = "";
|
||
state.mappingVmixScrollTop = 0;
|
||
setStatus("");
|
||
} catch (error) { setStatus(error.message, true); }
|
||
renderMapping();
|
||
}));
|
||
modal.querySelector("[data-mapping-create]")?.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
const form = new FormData(event.currentTarget);
|
||
try {
|
||
const created = await request("/api/hockey/admin/vmix-mapping/profiles", { method: "POST", body: JSON.stringify({ device_id: form.get("device_id"), name: form.get("name"), description: form.get("description") }) });
|
||
await loadMapping(created.id);
|
||
state.mappingSelectedInputKey = "";
|
||
state.mappingVmixScrollTop = 0;
|
||
setStatus(`Mapping «${created.name}» создан.`);
|
||
} 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 = "";
|
||
state.mappingSelectedSourceKey = "";
|
||
state.mappingVmixScrollTop = 0;
|
||
renderMapping();
|
||
});
|
||
modal.querySelector("[data-map-input-search]")?.addEventListener("input", (event) => {
|
||
state.mappingInputSearch = event.currentTarget.value;
|
||
const value = state.mappingInputSearch;
|
||
renderMapping();
|
||
const input = ensureModal().querySelector("[data-map-input-search]");
|
||
if (input) { input.focus(); input.setSelectionRange(value.length, value.length); }
|
||
});
|
||
modal.querySelectorAll("[data-map-field-filter]").forEach((button) => button.addEventListener("click", () => {
|
||
state.mappingFieldFilter = button.dataset.mapFieldFilter || "text";
|
||
state.mappingTargetField = "";
|
||
state.mappingVmixScrollTop = 0;
|
||
renderMapping();
|
||
}));
|
||
modal.querySelector("[data-map-hide-linked]")?.addEventListener("change", (event) => {
|
||
state.mappingHideLinked = Boolean(event.currentTarget.checked);
|
||
if (state.mappingHideLinked) state.mappingTargetField = "";
|
||
renderMapping();
|
||
});
|
||
modal.querySelector("[data-map-table-source]")?.addEventListener("change", (event) => {
|
||
state.mappingSelectedTableSource = event.currentTarget.value;
|
||
state.mappingSelectedTableRow = 1;
|
||
state.mappingSelectedTableColumn = "";
|
||
state.mappingTableRowSearch = "";
|
||
renderMapping();
|
||
});
|
||
modal.querySelectorAll("[data-map-data-group]").forEach((details) => details.addEventListener("toggle", (event) => {
|
||
const key = String(event.currentTarget.dataset.mapDataGroup || "");
|
||
if (key) state.mappingOpenDataGroups[key] = Boolean(event.currentTarget.open);
|
||
}));
|
||
modal.querySelector("[data-map-sql-cell-details]")?.addEventListener("toggle", (event) => {
|
||
state.mappingSqlCellOpen = Boolean(event.currentTarget.open);
|
||
});
|
||
modal.querySelector("[data-map-sql-table-details]")?.addEventListener("toggle", (event) => {
|
||
state.mappingSqlTableOpen = Boolean(event.currentTarget.open);
|
||
});
|
||
modal.querySelector("[data-map-quick-table-source]")?.addEventListener("change", (event) => {
|
||
state.mappingSelectedTableSource = event.currentTarget.value;
|
||
state.mappingSelectedTableRow = 1;
|
||
state.mappingSelectedTableColumn = "";
|
||
renderMapping();
|
||
});
|
||
modal.querySelector("[data-map-quick-table-row]")?.addEventListener("change", (event) => {
|
||
state.mappingSelectedTableRow = Number(event.currentTarget.value || 1);
|
||
state.mappingSelectedTableColumn = "";
|
||
renderMapping();
|
||
});
|
||
modal.querySelector("[data-map-quick-table-column]")?.addEventListener("change", (event) => {
|
||
state.mappingSelectedTableColumn = event.currentTarget.value;
|
||
renderMapping();
|
||
});
|
||
modal.querySelector("[data-map-table-row-search]")?.addEventListener("input", (event) => {
|
||
state.mappingTableRowSearch = event.currentTarget.value;
|
||
const value = state.mappingTableRowSearch;
|
||
renderMapping();
|
||
const input = ensureModal().querySelector("[data-map-table-row-search]");
|
||
if (input) { input.focus(); input.setSelectionRange(value.length, value.length); }
|
||
});
|
||
modal.querySelector("[data-map-test-device]")?.addEventListener("change", (event) => { state.mappingTestDeviceId = event.currentTarget.value; });
|
||
modal.querySelector("[data-map-data-search]")?.addEventListener("input", (event) => {
|
||
state.mappingDataSearch = event.currentTarget.value;
|
||
const value = state.mappingDataSearch;
|
||
renderMapping();
|
||
const input = ensureModal().querySelector("[data-map-data-search]");
|
||
if (input) { input.focus(); input.setSelectionRange(value.length, value.length); }
|
||
});
|
||
modal.querySelector("[data-map-live-refresh]")?.addEventListener("click", async () => { await loadMappingCatalog(); renderMapping(); });
|
||
|
||
const chooseTarget = (fieldName) => {
|
||
const profile = state.mappingActiveProfile;
|
||
const input = mappingCurrentInput(profile);
|
||
const field = (input?.fields || []).find((item) => String(item.name || "") === String(fieldName || ""));
|
||
if (!field) return;
|
||
state.mappingTargetField = fieldName;
|
||
if (state.mappingSelectedSourceKey) {
|
||
const source = mappingSourceByKey(state.mappingSelectedSourceKey);
|
||
if (source && mappingCompatible(source, mappingFieldKind(field))) {
|
||
mappingSetLink(profile, input, field, source);
|
||
state.mappingTargetField = "";
|
||
state.mappingSelectedSourceKey = "";
|
||
}
|
||
}
|
||
renderMapping();
|
||
};
|
||
modal.querySelectorAll("[data-map-target]").forEach((node) => node.addEventListener("click", (event) => {
|
||
// Controls inside a linked vMix card (especially rule <select>s) must not
|
||
// bubble into the card click handler. Otherwise opening a dropdown
|
||
// selects the whole field and renderMapping() destroys the dropdown.
|
||
if (event.target.closest("button, input, select, textarea, label, a, [contenteditable=\"true\"], .hockey-map-rule-wrap")) return;
|
||
chooseTarget(node.dataset.mapTarget);
|
||
}));
|
||
modal.querySelectorAll("[data-map-choose]").forEach((button) => button.addEventListener("click", () => chooseTarget(button.dataset.mapChoose)));
|
||
modal.querySelector("[data-map-quick-cell-link]")?.addEventListener("click", () => {
|
||
const profile = state.mappingActiveProfile;
|
||
const input = mappingCurrentInput(profile);
|
||
const field = (input?.fields || []).find((item) => String(item.name || "") === String(state.mappingTargetField || ""));
|
||
const table = (mappingDataCatalog().tables || []).find((item) => String(item.code || "") === String(state.mappingSelectedTableSource || ""));
|
||
const rowIndex = Number(state.mappingSelectedTableRow || 0);
|
||
const column = String(state.mappingSelectedTableColumn || "");
|
||
const key = table && rowIndex && column ? `${table.code}.row.${rowIndex}.${column}` : "";
|
||
const source = key ? mappingSourceByKey(key) : null;
|
||
if (!input || !field) return setStatus("Сначала выберите поле vMix справа.", true), renderMapping();
|
||
if (!source) return setStatus("Не удалось получить выбранную SQL-ячейку.", true), renderMapping();
|
||
if (!mappingCompatible(source, mappingFieldKind(field))) return setStatus(`Тип SQL-ячейки не подходит для ${field.name}.`, true), renderMapping();
|
||
mappingSetLink(profile, input, field, source);
|
||
state.mappingTargetField = "";
|
||
state.mappingSelectedSourceKey = "";
|
||
setStatus(`Связано: ${field.name} ← ${table.name} / строка ${rowIndex} / ${column} · сейчас «${mappingDisplayValue(source.value, source.kind)}»`);
|
||
renderMapping();
|
||
});
|
||
modal.querySelectorAll("[data-map-source]").forEach((button) => button.addEventListener("click", () => {
|
||
const key = button.dataset.mapSource;
|
||
const source = mappingSourceByKey(key);
|
||
const profile = state.mappingActiveProfile;
|
||
const input = mappingCurrentInput(profile);
|
||
if (!source || !input) return;
|
||
if (!state.mappingTargetField) {
|
||
state.mappingSelectedSourceKey = state.mappingSelectedSourceKey === key ? "" : key;
|
||
setStatus(state.mappingSelectedSourceKey ? `Выбрано «${source.label}». Теперь нажмите поле vMix справа.` : "");
|
||
return renderMapping();
|
||
}
|
||
const field = (input.fields || []).find((item) => String(item.name || "") === String(state.mappingTargetField));
|
||
if (!field) return;
|
||
if (!mappingCompatible(source, mappingFieldKind(field))) {
|
||
setStatus(`Тип данных не подходит для поля ${field.name}.`, true);
|
||
return renderMapping();
|
||
}
|
||
mappingSetLink(profile, input, field, source);
|
||
state.mappingTargetField = "";
|
||
state.mappingSelectedSourceKey = "";
|
||
setStatus(`Связано: ${field.name} ← ${source.label}`);
|
||
renderMapping();
|
||
}));
|
||
const runSeriesLink = (fieldName, mode) => {
|
||
const profile = state.mappingActiveProfile;
|
||
const input = mappingCurrentInput(profile);
|
||
if (!profile || !input) return;
|
||
const plan = mappingSeriesPlan(profile, input, fieldName, mode);
|
||
if (!plan.links.length) {
|
||
setStatus(plan.reason || "Не удалось построить серию связей.", true);
|
||
return renderMapping();
|
||
}
|
||
const anchor = mappingRowForField(profile, input, fieldName);
|
||
const seedCount = Number(plan.seedCount || 1);
|
||
const preview = plan.links.slice(0, 6).map((item) => `${item.field.name} ← row.${item.sourceRowIndex}.${item.column}`).join("\n");
|
||
const extra = plan.links.length > 6 ? `\n… ещё ${plan.links.length - 6}` : "";
|
||
const title = mode === "row"
|
||
? `Размножить ${seedCount} пол${seedCount === 1 ? "е" : "я"} шаблонной строки на всю серию?`
|
||
: "Автоматически продолжить эту колонку по строкам?";
|
||
if (!window.confirm(`${title}\n\nШаблон: ${fieldName} ← ${anchor?.data_key || ""}\nБудет обработано связей: ${plan.links.length}\n\n${preview}${extra}`)) return;
|
||
const result = mappingApplySeries(profile, input, fieldName, mode);
|
||
setStatus(`${mode === "row" ? "Строка размножена" : "Столбец автосвязан"}: ${result.changed} новых/изменённых связей${result.overwritten ? ` · перезаписано ${result.overwritten}` : ""}. Нажмите «Сохранить mapping».`);
|
||
renderMapping();
|
||
};
|
||
modal.querySelectorAll("[data-map-series-column]").forEach((button) => button.addEventListener("click", (event) => {
|
||
event.stopPropagation();
|
||
runSeriesLink(button.dataset.mapSeriesColumn, "column");
|
||
}));
|
||
modal.querySelectorAll("[data-map-series-row]").forEach((button) => button.addEventListener("click", (event) => {
|
||
event.stopPropagation();
|
||
runSeriesLink(button.dataset.mapSeriesRow, "row");
|
||
}));
|
||
|
||
modal.querySelectorAll("[data-map-rule-toggle]").forEach((button) => button.addEventListener("click", (event) => {
|
||
event.stopPropagation();
|
||
state.mappingRuleField = state.mappingRuleField === button.dataset.mapRuleToggle ? "" : button.dataset.mapRuleToggle;
|
||
renderMapping();
|
||
}));
|
||
modal.querySelectorAll("[data-map-rule-enabled]").forEach((control) => control.addEventListener("change", () => { mappingPatchRule(control.dataset.ruleField, { enabled: control.checked }); renderMapping(); }));
|
||
modal.querySelectorAll("[data-map-rule-action]").forEach((control) => control.addEventListener("change", () => {
|
||
const action = control.value;
|
||
mappingPatchRule(control.dataset.ruleField, { action, true_value: action === "visibility" ? "on" : "#E5CEA8", false_value: action === "visibility" ? "off" : "#FFFFFF", enabled: true });
|
||
renderMapping();
|
||
}));
|
||
modal.querySelectorAll("[data-map-rule-left-key]").forEach((control) => control.addEventListener("change", () => { mappingPatchRule(control.dataset.ruleField, { left_key: control.value, enabled: true }); renderMapping(); }));
|
||
modal.querySelectorAll("[data-map-rule-operator]").forEach((control) => control.addEventListener("change", () => { mappingPatchRule(control.dataset.ruleField, { operator: control.value, enabled: true }); renderMapping(); }));
|
||
modal.querySelectorAll("[data-map-rule-right-mode]").forEach((control) => control.addEventListener("change", () => { mappingPatchRule(control.dataset.ruleField, { right_mode: control.value, enabled: true }); renderMapping(); }));
|
||
modal.querySelectorAll("[data-map-rule-right-key]").forEach((control) => control.addEventListener("change", () => { mappingPatchRule(control.dataset.ruleField, { right_key: control.value, enabled: true }); renderMapping(); }));
|
||
modal.querySelectorAll("[data-map-rule-right-value]").forEach((control) => control.addEventListener("change", () => { mappingPatchRule(control.dataset.ruleField, { right_value: control.value, enabled: true }); renderMapping(); }));
|
||
modal.querySelectorAll("[data-map-rule-true]").forEach((control) => control.addEventListener("change", () => { mappingPatchRule(control.dataset.ruleField, { true_value: control.value, enabled: true }); renderMapping(); }));
|
||
modal.querySelectorAll("[data-map-rule-false]").forEach((control) => control.addEventListener("change", () => { mappingPatchRule(control.dataset.ruleField, { false_value: control.value, enabled: true }); renderMapping(); }));
|
||
modal.querySelectorAll("[data-map-rule-preset]").forEach((button) => button.addEventListener("click", () => { mappingPatchRule(button.dataset.ruleField, { operator: button.dataset.mapRulePreset, right_mode: "field", enabled: true }); renderMapping(); }));
|
||
modal.querySelectorAll("[data-map-rule-clear]").forEach((button) => button.addEventListener("click", () => {
|
||
const { row } = mappingRuleRow(state.mappingActiveProfile, mappingCurrentInput(state.mappingActiveProfile), button.dataset.mapRuleClear);
|
||
if (row) row.rule = {};
|
||
renderMapping();
|
||
}));
|
||
modal.querySelectorAll("[data-map-rule-mirror]").forEach((button) => button.addEventListener("click", () => {
|
||
const result = mappingMirrorRule(button.dataset.mapRuleMirror);
|
||
setStatus(result.ok ? `Зеркальное правило создано для ${result.target}. Нажмите «Сохранить mapping».` : result.reason, !result.ok);
|
||
renderMapping();
|
||
}));
|
||
modal.querySelectorAll("[data-map-rule-series]").forEach((button) => button.addEventListener("click", () => {
|
||
const result = mappingCloneRuleSeries(button.dataset.mapRuleSeries);
|
||
setStatus(result.changed ? `Правило размножено на ${result.changed} связанных полей. Нажмите «Сохранить mapping».` : "Не удалось найти связанную последовательность полей.", !result.changed);
|
||
renderMapping();
|
||
}));
|
||
|
||
modal.querySelectorAll("[data-map-unlink]").forEach((button) => button.addEventListener("click", (event) => {
|
||
event.stopPropagation();
|
||
mappingRemoveLink(state.mappingActiveProfile, mappingCurrentInput(state.mappingActiveProfile), button.dataset.mapUnlink);
|
||
if (state.mappingTargetField === button.dataset.mapUnlink) state.mappingTargetField = "";
|
||
renderMapping();
|
||
}));
|
||
modal.querySelectorAll("[data-map-test]").forEach((button) => button.addEventListener("click", async (event) => {
|
||
event.stopPropagation();
|
||
const profile = state.mappingActiveProfile;
|
||
const input = mappingCurrentInput(profile);
|
||
const field = (input?.fields || []).find((item) => String(item.name || "") === String(button.dataset.mapTest || ""));
|
||
const row = field ? mappingRowForField(profile, input, field.name) : null;
|
||
const source = row ? mappingSourceByKey(row.data_key) : null;
|
||
const deviceId = state.mappingTestDeviceId;
|
||
if (!deviceId) return setStatus("Выберите тестовый Agent.", true), renderMapping();
|
||
if (!row || !source) return setStatus("Сначала свяжите поле с данными.", true), renderMapping();
|
||
if (source.value === "" || source.value === null || source.value === undefined) return setStatus(`Для «${source.label}» в текущем матче нет значения.`, true), renderMapping();
|
||
const commands = [{
|
||
input: row.vmix_input_key || row.vmix_input_title || row.vmix_input_number,
|
||
selected_name: row.vmix_field, value: String(source.value),
|
||
field_type: row.field_type || mappingFieldKind(field), data_key: row.data_key,
|
||
}];
|
||
const ruleCommand = mappingRuleCommand(row, field);
|
||
if (ruleCommand) commands.push(ruleCommand);
|
||
button.disabled = true; button.textContent = "Отправка…";
|
||
try {
|
||
const result = await request("/api/hockey/admin/vmix-mapping/test-batch", { method: "POST", body: JSON.stringify({ device_id: deviceId, commands }) });
|
||
setStatus(`✓ ${row.vmix_field}: значение${ruleCommand ? " + правило" : ""} отправлено в vMix · ${Number(result.applied || 0)} команд.`);
|
||
} catch (error) { setStatus(error.message, true); }
|
||
renderMapping();
|
||
}));
|
||
modal.querySelector("[data-map-apply-input]")?.addEventListener("click", async () => {
|
||
const deviceId = state.mappingTestDeviceId || modal.querySelector("[data-map-test-device]")?.value || "";
|
||
if (!deviceId) return setStatus("Выберите online Agent для применения Input.", true), renderMapping();
|
||
await loadMappingCatalog();
|
||
const profile = state.mappingActiveProfile;
|
||
const input = mappingCurrentInput(profile);
|
||
const commands = [];
|
||
const missing = [];
|
||
for (const field of (input?.fields || [])) {
|
||
const row = mappingRowForField(profile, input, field.name);
|
||
if (!row) continue;
|
||
const source = mappingSourceByKey(row.data_key);
|
||
if (!source) { missing.push(row.data_key); continue; }
|
||
commands.push({
|
||
input: row.vmix_input_key || row.vmix_input_title || row.vmix_input_number,
|
||
selected_name: row.vmix_field,
|
||
value: source.value == null ? "" : String(source.value),
|
||
field_type: row.field_type || mappingFieldKind(field),
|
||
data_key: row.data_key,
|
||
});
|
||
const ruleCommand = mappingRuleCommand(row, field);
|
||
if (ruleCommand) commands.push(ruleCommand);
|
||
}
|
||
if (!commands.length) return setStatus("У текущего Input нет настроенных связей с доступными данными.", true), renderMapping();
|
||
try {
|
||
const result = await request("/api/hockey/admin/vmix-mapping/test-batch", { method: "POST", body: JSON.stringify({ device_id: deviceId, commands }) });
|
||
const tail = `${missing.length ? ` · нет данных ${missing.length}` : ""}${result.errors?.length ? ` · ошибок ${result.errors.length}` : ""}`;
|
||
setStatus(`Input ${input?.number ? `#${input.number} ` : ""}${input?.title || ""}: отправлено ${Number(result.applied || 0)} из ${Number(result.total || commands.length)} полей${tail}.`, Boolean(result.errors?.length));
|
||
} 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();
|
||
try {
|
||
const result = await request(`/api/hockey/admin/vmix-mapping/apply/${encodeURIComponent(deviceId)}`, { method: "POST" });
|
||
const suffix = result.errors?.length ? ` · ошибок ${result.errors.length}` : "";
|
||
setStatus(`Mapping применён: ${Number(result.applied || 0)} из ${Number(result.total || 0)} связей${suffix}.`);
|
||
} catch (error) { setStatus(error.message, true); }
|
||
renderMapping();
|
||
});
|
||
modal.querySelector("[data-map-save]")?.addEventListener("click", async () => {
|
||
if (!state.mappingActiveProfile) return;
|
||
const currentScroll = modal.querySelector(".hockey-map-vmix-scroll");
|
||
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: 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); }
|
||
renderMapping();
|
||
});
|
||
modal.querySelector("[data-map-refresh]")?.addEventListener("click", async () => {
|
||
const deviceId = modal.querySelector("[data-map-refresh-device]")?.value || "";
|
||
if (!deviceId) return setStatus("Выберите Agent, из которого нужно считать структуру.", true), renderMapping();
|
||
try {
|
||
const result = await request(`/api/hockey/admin/vmix-mapping/profiles/${state.mappingActiveProfile.id}/inventory`, { method: "POST", body: JSON.stringify({ device_id: deviceId }) });
|
||
await loadMapping(result.id); state.mappingSelectedInputKey = ""; state.mappingVmixScrollTop = 0;
|
||
const report = result.refresh_report || {};
|
||
const preserved = Number(report.preserved || 0);
|
||
const total = Number(report.total_links || preserved);
|
||
const addedInputs = Number(report.new_inputs || 0);
|
||
const addedFields = Number(report.new_fields || 0);
|
||
const unresolved = Number(report.unresolved || 0);
|
||
setStatus(`Структура vMix обновлена · связи ${preserved}/${total} сохранены${addedInputs ? ` · новых Inputs ${addedInputs}` : ""}${addedFields ? ` · новых полей ${addedFields}` : ""}${unresolved ? ` · требуют проверки ${unresolved}` : ""}.`, unresolved > 0);
|
||
} catch (error) { setStatus(error.message, true); }
|
||
renderMapping();
|
||
});
|
||
modal.querySelector("[data-map-delete]")?.addEventListener("click", async () => {
|
||
if (!state.mappingActiveProfile || !window.confirm(`Удалить mapping «${state.mappingActiveProfile.name}»?`)) return;
|
||
try {
|
||
await request(`/api/hockey/admin/vmix-mapping/profiles/${state.mappingActiveProfile.id}`, { method: "DELETE" });
|
||
state.mappingActiveProfile = null; state.mappingSelectedInputKey = ""; state.mappingVmixScrollTop = 0; await loadMapping(); setStatus("Mapping удалён.");
|
||
} catch (error) { setStatus(error.message, true); }
|
||
renderMapping();
|
||
});
|
||
}
|
||
|
||
function renderKhlSite() {
|
||
if (!isKhlTournamentSelected()) {
|
||
state.section = "teams";
|
||
return renderTeams();
|
||
}
|
||
const context = selectedContext();
|
||
const title = context.tournament?.name || context.tournament?.full_name || `Турнир ${context.tournamentId || "КХЛ"}`;
|
||
const content = `
|
||
<div class="hockey-khl-site-head">
|
||
<div><span>КХЛ · официальный сайт</span><strong>${escapeHtml(title)}</strong></div>
|
||
<small>Игроки, тренеры, судьи и статистика KHL.ru</small>
|
||
</div>
|
||
<div class="hockey-khl-site-frame-wrap">
|
||
<iframe class="hockey-khl-site-frame" src="/khl-site/" title="КХЛ сайт" loading="eager"></iframe>
|
||
</div>`;
|
||
const modal = ensureModal();
|
||
modal.innerHTML = shellMarkup(content);
|
||
bindShell();
|
||
}
|
||
|
||
function render() {
|
||
if (!state.open) return;
|
||
if (state.section === "timer_rules") renderTimerRules();
|
||
else if (state.section === "penalties") renderPenalties();
|
||
else if (state.section === "vmix") renderVmixSettings();
|
||
else if (isMappingSection()) renderMapping();
|
||
else if (state.section === "khl_site") renderKhlSite();
|
||
else if (state.section === "teams") renderTeams();
|
||
else renderGeneric(state.section);
|
||
}
|
||
|
||
function bindShell() {
|
||
const modal = ensureModal();
|
||
modal.querySelector("[data-directory-close]")?.addEventListener("click", closeModal);
|
||
modal.querySelectorAll("[data-directory-section]").forEach((button) => {
|
||
button.addEventListener("click", async () => {
|
||
state.section = button.dataset.directorySection;
|
||
if (isMappingSection()) state.mappingWorkspaceTab = mappingTabFromSection();
|
||
state.editingTeam = null;
|
||
state.editingPenalty = null;
|
||
state.editingRecord = null;
|
||
state.status = "";
|
||
try {
|
||
if (state.section === "timer_rules") await loadTimerRules();
|
||
else if (state.section === "penalties") await loadPenalties(true);
|
||
else if (state.section === "vmix") await loadVmixSettings();
|
||
else if (isMappingSection()) await loadMapping();
|
||
else if (state.section === "khl_site") { /* iframe loads its own KHL module */ }
|
||
else if (state.section === "teams") await loadTeams();
|
||
else await loadGeneric(state.section);
|
||
} catch (error) {
|
||
setStatus(error.message, true);
|
||
}
|
||
render();
|
||
});
|
||
});
|
||
}
|
||
|
||
function bindTeams() {
|
||
const modal = ensureModal();
|
||
modal.querySelector("[data-team-league-filter]")?.addEventListener("change", async (event) => {
|
||
state.teamLeague = event.target.value;
|
||
state.editingTeam = null;
|
||
try {
|
||
await loadTeams();
|
||
setStatus("");
|
||
} catch (error) {
|
||
setStatus(error.message, true);
|
||
}
|
||
renderTeams();
|
||
});
|
||
modal.querySelector("[data-sync-teams]")?.addEventListener("click", async (event) => {
|
||
const button = event.currentTarget;
|
||
const tournamentId = selectedContext().tournamentId;
|
||
const params = new URLSearchParams();
|
||
if (tournamentId) params.set("tournament_external_id", tournamentId);
|
||
button.disabled = true;
|
||
try {
|
||
const result = await request(`/api/hockey/directories/teams/sync?${params}`, { method: "POST" });
|
||
await loadTeams();
|
||
setStatus(`Справочник обновлён: новых — ${result.created}, обновлено — ${result.updated}.`);
|
||
} catch (error) {
|
||
setStatus(error.message, true);
|
||
}
|
||
renderTeams();
|
||
});
|
||
modal.querySelector("[data-new-team]")?.addEventListener("click", () => {
|
||
state.editingTeam = null;
|
||
setStatus("");
|
||
renderTeams();
|
||
modal.querySelector("[data-team-form]")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
});
|
||
modal.querySelectorAll("[data-edit-team]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
state.editingTeam = state.teams.items.find((item) => String(item.id) === button.dataset.editTeam) || null;
|
||
setStatus("");
|
||
renderTeams();
|
||
});
|
||
});
|
||
modal.querySelector("[data-cancel-team]")?.addEventListener("click", () => {
|
||
state.editingTeam = null;
|
||
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") || "",
|
||
tournament_external_id: values.get("tournament_external_id") || "",
|
||
name_ru: values.get("name_ru") || "",
|
||
name_en: values.get("name_en") || "",
|
||
short_name_ru: values.get("short_name_ru") || "",
|
||
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"),
|
||
};
|
||
const editingId = state.editingTeam?.id;
|
||
try {
|
||
await request(
|
||
editingId ? `/api/hockey/directories/teams/${editingId}` : "/api/hockey/directories/teams",
|
||
{ method: editingId ? "PUT" : "POST", body: JSON.stringify(payload) }
|
||
);
|
||
state.editingTeam = null;
|
||
await loadTeams();
|
||
setStatus("Команда сохранена.");
|
||
} catch (error) {
|
||
setStatus(error.message, true);
|
||
}
|
||
renderTeams();
|
||
});
|
||
}
|
||
|
||
function bindPenalties() {
|
||
const modal = ensureModal();
|
||
modal.querySelector("[data-new-penalty]")?.addEventListener("click", () => {
|
||
state.editingPenalty = null;
|
||
setStatus("");
|
||
renderPenalties();
|
||
});
|
||
modal.querySelectorAll("[data-edit-penalty]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
state.editingPenalty = state.penalties.items.find((item) => String(item.id) === button.dataset.editPenalty) || null;
|
||
setStatus("");
|
||
renderPenalties();
|
||
});
|
||
});
|
||
modal.querySelector("[data-cancel-penalty]")?.addEventListener("click", () => {
|
||
state.editingPenalty = null;
|
||
setStatus("");
|
||
renderPenalties();
|
||
});
|
||
modal.querySelector("[data-penalty-form]")?.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
const values = new FormData(event.currentTarget);
|
||
const rawOrder = String(values.get("sort_order") || "").trim();
|
||
const payload = {
|
||
code: values.get("code") || "",
|
||
name_ru: values.get("name_ru") || "",
|
||
name_en: values.get("name_en") || "",
|
||
default_preset: values.get("default_preset") || "2",
|
||
team_penalty: values.has("team_penalty"),
|
||
sort_order: rawOrder ? Number(rawOrder) : null,
|
||
active: values.has("active"),
|
||
};
|
||
const editingId = state.editingPenalty?.id;
|
||
try {
|
||
await request(
|
||
editingId ? `/api/hockey/directories/penalties/${editingId}` : "/api/hockey/directories/penalties",
|
||
{ method: editingId ? "PUT" : "POST", body: JSON.stringify(payload) }
|
||
);
|
||
state.editingPenalty = null;
|
||
await loadPenalties(true);
|
||
setStatus("Удаление сохранено и обновлено в основном меню.");
|
||
window.dispatchEvent(new CustomEvent("hockey:directories-updated", { detail: { type: "penalties" } }));
|
||
} catch (error) {
|
||
setStatus(error.message, true);
|
||
}
|
||
renderPenalties();
|
||
});
|
||
}
|
||
|
||
async function openModal() {
|
||
if (!state.isAdmin) return;
|
||
state.open = true;
|
||
document.body.classList.add("hockey-admin-settings-open");
|
||
window.dispatchEvent(new CustomEvent("hockey:admin-settings-visibility", { detail: { open: true } }));
|
||
state.status = "";
|
||
const modal = ensureModal();
|
||
modal.classList.remove("hidden");
|
||
modal.innerHTML = shellMarkup(`<div class="hockey-directory-loading">Загрузка справочников…</div>`);
|
||
bindShell();
|
||
try {
|
||
if (state.section === "timer_rules") await loadTimerRules();
|
||
else if (state.section === "penalties") await loadPenalties(true);
|
||
else if (state.section === "vmix") await loadVmixSettings();
|
||
else if (isMappingSection()) await loadMapping();
|
||
else if (state.section === "khl_site") { if (!isKhlTournamentSelected()) state.section = "teams"; }
|
||
else if (state.section === "teams") await loadTeams();
|
||
else await loadGeneric(state.section);
|
||
} catch (error) {
|
||
setStatus(error.message, true);
|
||
}
|
||
render();
|
||
}
|
||
|
||
async function init() {
|
||
const gear = document.getElementById("runtimeAdminSettingsBtn");
|
||
gear?.addEventListener("click", openModal);
|
||
|
||
loadPenalties(false).catch(() => {
|
||
// The hard-coded v20.2 list remains available when the API is offline.
|
||
});
|
||
try {
|
||
const user = await request("/api/hockey/me");
|
||
state.isAdmin = Boolean(user.is_admin);
|
||
gear?.classList.toggle("hidden", !state.isAdmin);
|
||
} catch (_) {
|
||
gear?.classList.add("hidden");
|
||
}
|
||
}
|
||
|
||
window.addEventListener("hockey:game-selected", async (event) => {
|
||
state.language = event.detail?.language === "en" ? "en" : "ru";
|
||
try {
|
||
await loadPenalties(state.isAdmin && state.open && state.section === "penalties");
|
||
if (state.open && state.section === "teams") await loadTeams();
|
||
else if (state.open && state.section === "timer_rules") await loadTimerRules();
|
||
else if (state.open && state.section === "vmix") await loadVmixSettings(true);
|
||
else if (state.open && isMappingSection()) await loadMapping();
|
||
else if (state.open && state.section === "khl_site" && !isKhlTournamentSelected()) state.section = "teams";
|
||
else if (state.open && genericSections[state.section]) await loadGeneric(state.section);
|
||
} catch (_) {
|
||
return;
|
||
}
|
||
render();
|
||
});
|
||
|
||
window.addEventListener("hockey:settings-updated", async (event) => {
|
||
const lang = String(event.detail?.settings?.ui_language || event.detail?.settings?.settings?.ui_language || "").toLowerCase();
|
||
if (lang === "ru" || lang === "en") state.language = lang;
|
||
if (!state.open || !isMappingSection()) return;
|
||
try { await loadMappingCatalog(); } catch (_) {}
|
||
render();
|
||
});
|
||
|
||
window.addEventListener("hockey:mapping-context-updated", () => {
|
||
if (!state.open || !isMappingSection()) return;
|
||
if (state.mappingContextRefreshTimer) clearTimeout(state.mappingContextRefreshTimer);
|
||
state.mappingContextRefreshTimer = setTimeout(async () => {
|
||
state.mappingContextRefreshTimer = null;
|
||
try { await loadMappingCatalog(); } catch (_) {}
|
||
renderMapping();
|
||
}, 80);
|
||
});
|
||
|
||
window.addEventListener("hockey:tournament-selected", () => {
|
||
if (!state.open) return;
|
||
if (state.section === "khl_site" && !isKhlTournamentSelected()) state.section = "teams";
|
||
render();
|
||
});
|
||
|
||
document.addEventListener("keydown", (event) => {
|
||
if (event.key === "Escape" && state.open) closeModal();
|
||
});
|
||
if (document.readyState === "loading") {
|
||
document.addEventListener("DOMContentLoaded", init, { once: true });
|
||
} else {
|
||
init();
|
||
}
|
||
})();
|