я косяк, поправил веб. Капитан, работайте!
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* Модалка «Учётные документы» на /feed_consumption
|
||||
* Сценарий: Реквизиты → Подписи → Экспорт
|
||||
*/
|
||||
(function () {
|
||||
const ORG_FIELDS = [
|
||||
{ id: "faOrgName", key: "organization_name", label: "Организация" },
|
||||
{ id: "faOkpo", key: "okpo", label: "ОКПО" },
|
||||
{ id: "faDepartment", key: "department", label: "Отделение (участок)" },
|
||||
{ id: "faFarm", key: "farm_name", label: "Ферма" },
|
||||
{ id: "faBrigade", key: "brigade", label: "Бригада" },
|
||||
{ id: "faAnimalType", key: "animal_type", label: "Вид / группа скота" },
|
||||
{ id: "faResponsible", key: "responsible_person", label: "Ответственный" },
|
||||
{ id: "faZootech", key: "zootechnician", label: "Зоотехник (ФИО)" },
|
||||
{ id: "faWarehouse", key: "warehouse_keeper", label: "Кладовщик (ФИО)" },
|
||||
{ id: "faDocPrefix", key: "document_number_prefix", label: "Префикс номера документа" },
|
||||
];
|
||||
|
||||
const SIGNATURES = [
|
||||
{ role: "zootechnician", label: "Зоотехник", canvasId: "faSigZootech" },
|
||||
{ role: "warehouse_keeper", label: "Кладовщик", canvasId: "faSigWarehouse" },
|
||||
{ role: "recipient", label: "Получатель", canvasId: "faSigRecipient" },
|
||||
];
|
||||
|
||||
const canvases = {};
|
||||
let lastSignatureStatus = {};
|
||||
|
||||
function getExportMonth() {
|
||||
const monthEl = document.getElementById("faExportMonth");
|
||||
if (monthEl && monthEl.value) {
|
||||
return monthEl.value;
|
||||
}
|
||||
const fromEl = document.getElementById("consumptionDateFrom");
|
||||
if (fromEl && fromEl.value) {
|
||||
return fromEl.value.slice(0, 7);
|
||||
}
|
||||
const now = new Date();
|
||||
return now.getFullYear() + "-" + String(now.getMonth() + 1).padStart(2, "0");
|
||||
}
|
||||
|
||||
function getDateRange() {
|
||||
const fromEl = document.getElementById("consumptionDateFrom");
|
||||
const toEl = document.getElementById("consumptionDateTo");
|
||||
let from = fromEl && fromEl.value;
|
||||
let to = toEl && toEl.value;
|
||||
if (!from || !to) {
|
||||
const now = new Date();
|
||||
from = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
|
||||
to = new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().slice(0, 10);
|
||||
}
|
||||
return { from, to, month: getExportMonth() };
|
||||
}
|
||||
|
||||
function syncExportMonthField() {
|
||||
const monthEl = document.getElementById("faExportMonth");
|
||||
if (!monthEl || monthEl.value) return;
|
||||
monthEl.value = getExportMonth();
|
||||
}
|
||||
|
||||
async function refreshSp20Status() {
|
||||
const el = document.getElementById("faSp20Status");
|
||||
if (!el) return;
|
||||
const month = getExportMonth();
|
||||
el.textContent = "Проверка данных за " + month + "…";
|
||||
try {
|
||||
const r = await fetch("/api/feed-accounting/sp20-preview?month=" + encodeURIComponent(month), {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!r.ok) {
|
||||
el.textContent = "Не удалось загрузить сводку по СП-20.";
|
||||
return;
|
||||
}
|
||||
const data = await r.json();
|
||||
if (!data.lines_count) {
|
||||
el.textContent = "За " + month + " нет строк расхода в отчётах загрузки. Проверьте месяц или наличие отчётов.";
|
||||
el.className = "text-warning small mb-3";
|
||||
return;
|
||||
}
|
||||
let msg = "СП-20 за " + month + ": " + data.lines_count + " строк, " + data.total_kg + " кг";
|
||||
if (!data.has_requisites) {
|
||||
msg += ". Заполните реквизиты на вкладке «Реквизиты СП-20».";
|
||||
el.className = "text-warning small mb-3";
|
||||
} else {
|
||||
el.className = "text-success small mb-3";
|
||||
}
|
||||
el.textContent = msg;
|
||||
} catch (err) {
|
||||
el.textContent = "Не удалось загрузить сводку по СП-20.";
|
||||
el.className = "text-danger small mb-3";
|
||||
}
|
||||
}
|
||||
|
||||
function userError(message, fallback) {
|
||||
const notifyApi = window.WespZootechNotify;
|
||||
if (notifyApi && typeof notifyApi.userFacingMessage === "function") {
|
||||
return notifyApi.userFacingMessage(message, fallback);
|
||||
}
|
||||
return fallback || "Не удалось выполнить операцию. Попробуйте ещё раз.";
|
||||
}
|
||||
|
||||
function notify(msg, type) {
|
||||
if (typeof notyf !== "undefined") {
|
||||
if (type === "error") notyf.error(msg);
|
||||
else notyf.success(msg);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadUrl(url, label) {
|
||||
const fallback = "Не удалось скачать документ. Попробуйте ещё раз.";
|
||||
try {
|
||||
const r = await fetch(url, { credentials: "same-origin" });
|
||||
if (!r.ok) {
|
||||
let body = null;
|
||||
try {
|
||||
body = await r.json();
|
||||
} catch (e) { /* ignore */ }
|
||||
const notifyApi = window.WespZootechNotify;
|
||||
const msg = notifyApi && typeof notifyApi.messageFromResponseBody === "function"
|
||||
? notifyApi.messageFromResponseBody(body, fallback)
|
||||
: userError(null, fallback);
|
||||
notify(msg, "error");
|
||||
return;
|
||||
}
|
||||
const blob = await r.blob();
|
||||
const cd = r.headers.get("Content-Disposition") || "";
|
||||
const m = cd.match(/filename="([^"]+)"/);
|
||||
const filename = m ? m[1] : "download";
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(function () { URL.revokeObjectURL(a.href); }, 5000);
|
||||
} catch (err) {
|
||||
notify(userError(null, fallback), "error");
|
||||
}
|
||||
}
|
||||
|
||||
function showTab(name) {
|
||||
document.querySelectorAll(".fa-tab-panel").forEach(function (el) {
|
||||
el.hidden = el.dataset.tab !== name;
|
||||
});
|
||||
document.querySelectorAll(".fa-tab-btn").forEach(function (btn) {
|
||||
btn.classList.toggle("active", btn.dataset.tab === name);
|
||||
});
|
||||
if (name === "export") {
|
||||
syncExportMonthField();
|
||||
refreshSp20Status();
|
||||
}
|
||||
}
|
||||
|
||||
function setupCanvas(canvas) {
|
||||
if (!canvas || canvases[canvas.id]) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.strokeStyle = "#1a1a1a";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.lineCap = "round";
|
||||
let drawing = false;
|
||||
function pos(e) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
const ev = e.touches ? e.touches[0] : e;
|
||||
return {
|
||||
x: (ev.clientX - rect.left) * scaleX,
|
||||
y: (ev.clientY - rect.top) * scaleY,
|
||||
};
|
||||
}
|
||||
function start(e) {
|
||||
drawing = true;
|
||||
const p = pos(e);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p.x, p.y);
|
||||
e.preventDefault();
|
||||
}
|
||||
function move(e) {
|
||||
if (!drawing) return;
|
||||
const p = pos(e);
|
||||
ctx.lineTo(p.x, p.y);
|
||||
ctx.stroke();
|
||||
e.preventDefault();
|
||||
}
|
||||
function end() { drawing = false; }
|
||||
canvas.addEventListener("mousedown", start);
|
||||
canvas.addEventListener("mousemove", move);
|
||||
canvas.addEventListener("mouseup", end);
|
||||
canvas.addEventListener("mouseleave", end);
|
||||
canvas.addEventListener("touchstart", start, { passive: false });
|
||||
canvas.addEventListener("touchmove", move, { passive: false });
|
||||
canvas.addEventListener("touchend", end);
|
||||
canvases[canvas.id] = { canvas: canvas, ctx: ctx };
|
||||
}
|
||||
|
||||
function clearCanvas(canvasId) {
|
||||
const item = canvases[canvasId];
|
||||
if (!item) return;
|
||||
item.ctx.clearRect(0, 0, item.canvas.width, item.canvas.height);
|
||||
}
|
||||
|
||||
function canvasHasInk(canvasId) {
|
||||
const item = canvases[canvasId];
|
||||
if (!item) return false;
|
||||
const data = item.ctx.getImageData(0, 0, item.canvas.width, item.canvas.height).data;
|
||||
for (let i = 3; i < data.length; i += 4) {
|
||||
if (data[i] > 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function fillFarmDatalist(farms) {
|
||||
const list = document.getElementById("faFarmList");
|
||||
if (!list) return;
|
||||
list.innerHTML = "";
|
||||
(farms || []).forEach(function (farm) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = farm;
|
||||
list.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function isSetupComplete(settings, sigStatus) {
|
||||
const hasRequisites = Boolean(
|
||||
(settings.organization_name || "").trim() &&
|
||||
(settings.zootechnician || "").trim() &&
|
||||
(settings.warehouse_keeper || "").trim()
|
||||
);
|
||||
const hasSignatures = Boolean(sigStatus.zootechnician && sigStatus.warehouse_keeper);
|
||||
return hasRequisites && hasSignatures;
|
||||
}
|
||||
|
||||
async function loadOrgSettings() {
|
||||
const r = await fetch("/api/feed-accounting/org-settings", { credentials: "same-origin" });
|
||||
if (!r.ok) return null;
|
||||
const data = await r.json();
|
||||
ORG_FIELDS.forEach(function (f) {
|
||||
const el = document.getElementById(f.id);
|
||||
if (el) el.value = data[f.key] || "";
|
||||
});
|
||||
fillFarmDatalist(data.available_farms || []);
|
||||
if (data.signatures) {
|
||||
lastSignatureStatus = data.signatures;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadSignaturesOntoCanvases() {
|
||||
const roles = lastSignatureStatus;
|
||||
await Promise.all(
|
||||
SIGNATURES.map(function (s) {
|
||||
if (!roles[s.role]) return Promise.resolve();
|
||||
return new Promise(function (resolve) {
|
||||
const img = new Image();
|
||||
img.onload = function () {
|
||||
const item = canvases[s.canvasId];
|
||||
if (item) {
|
||||
item.ctx.clearRect(0, 0, item.canvas.width, item.canvas.height);
|
||||
item.ctx.drawImage(img, 0, 0, item.canvas.width, item.canvas.height);
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
img.onerror = function () { resolve(); };
|
||||
img.src = "/api/feed-accounting/signatures/" + s.role + ".png?t=" + Date.now();
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function saveOrgSettings(silent) {
|
||||
const payload = {};
|
||||
ORG_FIELDS.forEach(function (f) {
|
||||
const el = document.getElementById(f.id);
|
||||
if (el) payload[f.key] = el.value.trim();
|
||||
});
|
||||
const r = await fetch("/api/feed-accounting/org-settings", {
|
||||
method: "PUT",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) {
|
||||
notify(userError(data.message, "Не удалось сохранить реквизиты"), "error");
|
||||
return false;
|
||||
}
|
||||
if (!silent) notify("Реквизиты сохранены");
|
||||
return true;
|
||||
}
|
||||
|
||||
async function saveSignature(role, canvasId, silent) {
|
||||
const item = canvases[canvasId];
|
||||
if (!item || !canvasHasInk(canvasId)) return true;
|
||||
const dataUrl = item.canvas.toDataURL("image/png");
|
||||
const r = await fetch("/api/feed-accounting/signatures/" + role, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ png_base64: dataUrl }),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) {
|
||||
notify(userError(data.message, "Не удалось сохранить подпись"), "error");
|
||||
return false;
|
||||
}
|
||||
if (data.roles) lastSignatureStatus = data.roles;
|
||||
if (!silent) notify("Подпись сохранена");
|
||||
return true;
|
||||
}
|
||||
|
||||
async function saveAll() {
|
||||
const btn = document.getElementById("faSaveAllBtn");
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const orgOk = await saveOrgSettings(true);
|
||||
if (!orgOk) return;
|
||||
for (const s of SIGNATURES) {
|
||||
const ok = await saveSignature(s.role, s.canvasId, true);
|
||||
if (!ok) return;
|
||||
}
|
||||
notify("Реквизиты и подписи сохранены");
|
||||
const settings = await loadOrgSettings();
|
||||
if (settings && isSetupComplete(settings, lastSignatureStatus)) {
|
||||
showTab("export");
|
||||
}
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
window.openFeedAccountingModal = async function () {
|
||||
const modal = document.getElementById("feedAccountingModal");
|
||||
if (!modal) return;
|
||||
modal.style.display = "flex";
|
||||
document.body.style.overflow = "hidden";
|
||||
SIGNATURES.forEach(function (s) {
|
||||
setupCanvas(document.getElementById(s.canvasId));
|
||||
});
|
||||
const settings = await loadOrgSettings();
|
||||
await loadSignaturesOntoCanvases();
|
||||
syncExportMonthField();
|
||||
const complete = settings && isSetupComplete(settings, lastSignatureStatus);
|
||||
showTab(complete ? "export" : "requisites");
|
||||
};
|
||||
|
||||
window.closeFeedAccountingModal = function () {
|
||||
const modal = document.getElementById("feedAccountingModal");
|
||||
if (!modal) return;
|
||||
modal.style.display = "none";
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const openBtn = document.getElementById("openFeedAccountingBtn");
|
||||
if (openBtn) openBtn.addEventListener("click", function () { openFeedAccountingModal(); });
|
||||
|
||||
document.querySelectorAll(".fa-tab-btn").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
showTab(btn.dataset.tab);
|
||||
});
|
||||
});
|
||||
|
||||
const exportMonthEl = document.getElementById("faExportMonth");
|
||||
if (exportMonthEl) {
|
||||
exportMonthEl.addEventListener("change", refreshSp20Status);
|
||||
}
|
||||
|
||||
const saveAllBtn = document.getElementById("faSaveAllBtn");
|
||||
if (saveAllBtn) saveAllBtn.addEventListener("click", saveAll);
|
||||
|
||||
SIGNATURES.forEach(function (s) {
|
||||
const clearBtn = document.getElementById("clear_" + s.canvasId);
|
||||
if (clearBtn) clearBtn.addEventListener("click", function () { clearCanvas(s.canvasId); });
|
||||
});
|
||||
|
||||
const exports = [
|
||||
{ id: "faExportConsumptionXlsx", path: function (d) { return "/api/feed-accounting/consumption.xlsx?date_from=" + encodeURIComponent(d.from) + "&date_to=" + encodeURIComponent(d.to); }, label: "Потребление Excel" },
|
||||
{ id: "faExportConsumptionPdf", path: function (d) { return "/api/feed-accounting/consumption.pdf?date_from=" + encodeURIComponent(d.from) + "&date_to=" + encodeURIComponent(d.to); }, label: "Потребление PDF" },
|
||||
{ id: "faExportStock", path: function (d) { return "/api/feed-accounting/stock-balances.xlsx?date_from=" + encodeURIComponent(d.from) + "&date_to=" + encodeURIComponent(d.to); }, label: "Остатки" },
|
||||
{ id: "faExportSp20Xlsx", path: function (d) { return "/api/feed-accounting/sp20.xlsx?month=" + encodeURIComponent(d.month); }, label: "СП-20 Excel" },
|
||||
{ id: "faExportSp20Pdf", path: function (d) { return "/api/feed-accounting/sp20.pdf?month=" + encodeURIComponent(d.month); }, label: "СП-20 PDF" },
|
||||
{ id: "faExportJournalXlsx", path: function (d) { return "/api/feed-accounting/journal.xlsx?month=" + encodeURIComponent(d.month); }, label: "Журнал Excel" },
|
||||
{ id: "faExportJournalPdf", path: function (d) { return "/api/feed-accounting/journal.pdf?month=" + encodeURIComponent(d.month); }, label: "Журнал PDF" },
|
||||
{ id: "faExportZip", path: function (d) { return "/api/feed-accounting/documents.zip?date_from=" + encodeURIComponent(d.from) + "&date_to=" + encodeURIComponent(d.to) + "&month=" + encodeURIComponent(d.month); }, label: "ZIP" },
|
||||
];
|
||||
exports.forEach(function (ex) {
|
||||
const btn = document.getElementById(ex.id);
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", function () {
|
||||
const d = getDateRange();
|
||||
downloadUrl(ex.path(d), ex.label);
|
||||
});
|
||||
});
|
||||
|
||||
const modal = document.getElementById("feedAccountingModal");
|
||||
if (modal) {
|
||||
modal.addEventListener("click", function (e) {
|
||||
if (e.target === modal) closeFeedAccountingModal();
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Модальное окно «Калибровка»: iframe с /calibration?embed=1.
|
||||
*/
|
||||
(function () {
|
||||
const LOG = "[WespKioskCalibration]";
|
||||
const MENU_OVERLAY_ID = "wespKioskMenuOverlay";
|
||||
|
||||
function ensureStylesheet() {
|
||||
if (document.getElementById("kioskCalibrationModalCss")) return;
|
||||
const link = document.createElement("link");
|
||||
link.id = "kioskCalibrationModalCss";
|
||||
link.rel = "stylesheet";
|
||||
link.href = "/static/css/kiosk-calibration-modal.css";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
function resizeFrame(iframe) {
|
||||
if (!iframe || !iframe.contentDocument) return;
|
||||
try {
|
||||
const doc = iframe.contentDocument;
|
||||
const height = Math.max(
|
||||
doc.body?.scrollHeight || 0,
|
||||
doc.documentElement?.scrollHeight || 0,
|
||||
320,
|
||||
);
|
||||
const max = Math.floor(window.innerHeight * 0.82) - 64;
|
||||
iframe.style.height = `${Math.min(height + 4, max)}px`;
|
||||
} catch (e) {
|
||||
console.warn(LOG, "iframe resize", e);
|
||||
}
|
||||
}
|
||||
|
||||
function createModal(options) {
|
||||
ensureStylesheet();
|
||||
const opts = options || {};
|
||||
const zIndex = Number(opts.zIndex) > 0 ? Number(opts.zIndex) : 5000;
|
||||
const light = opts.theme === "light";
|
||||
const panelTheme = light ? "light" : "dark";
|
||||
|
||||
const modal = document.createElement("div");
|
||||
modal.id = "kioskCalibrationModal";
|
||||
modal.className = "kiosk-calibration-backdrop";
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
modal.style.zIndex = String(zIndex);
|
||||
modal.innerHTML = `
|
||||
<div class="kiosk-calibration-panel kiosk-calibration-panel--${panelTheme}" role="dialog" aria-modal="true" aria-labelledby="kioskCalibrationTitle">
|
||||
<div class="kiosk-calibration-head">
|
||||
<h2 class="kiosk-calibration-title" id="kioskCalibrationTitle">Калибровка весов</h2>
|
||||
<button type="button" class="kiosk-calibration-close" id="kioskCalibrationClose">Закрыть</button>
|
||||
</div>
|
||||
<iframe
|
||||
id="kioskCalibrationFrame"
|
||||
class="kiosk-calibration-frame"
|
||||
title="Калибровка весов"
|
||||
></iframe>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const iframe = modal.querySelector("#kioskCalibrationFrame");
|
||||
const closeBtn = modal.querySelector("#kioskCalibrationClose");
|
||||
const panel = modal.querySelector(".kiosk-calibration-panel");
|
||||
|
||||
function closeModal() {
|
||||
modal.classList.remove("is-open");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
try {
|
||||
iframe.src = "about:blank";
|
||||
iframe.style.height = "";
|
||||
} catch (e) {
|
||||
console.warn(LOG, "iframe reset", e);
|
||||
}
|
||||
document.removeEventListener("keydown", onKey);
|
||||
}
|
||||
|
||||
function onKey(e) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
const menu = document.getElementById(MENU_OVERLAY_ID);
|
||||
if (menu) menu.style.display = "none";
|
||||
|
||||
iframe.onload = function () {
|
||||
window.setTimeout(function () {
|
||||
resizeFrame(iframe);
|
||||
}, 50);
|
||||
};
|
||||
iframe.src = "/calibration?embed=1";
|
||||
modal.classList.add("is-open");
|
||||
modal.setAttribute("aria-hidden", "false");
|
||||
document.addEventListener("keydown", onKey);
|
||||
}
|
||||
|
||||
closeBtn.addEventListener("click", closeModal);
|
||||
modal.addEventListener("click", function (e) {
|
||||
if (e.target === modal) closeModal();
|
||||
});
|
||||
if (panel) {
|
||||
panel.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
}
|
||||
|
||||
return { modal, iframe, openModal, closeModal, resizeFrame };
|
||||
}
|
||||
|
||||
function ensureModal(options) {
|
||||
const opts = options || {};
|
||||
if (!window.__wespKioskCalibrationApi) {
|
||||
window.__wespKioskCalibrationApi = createModal(opts);
|
||||
} else if (Number(opts.zIndex) > 0) {
|
||||
window.__wespKioskCalibrationApi.modal.style.zIndex = String(opts.zIndex);
|
||||
}
|
||||
return window.__wespKioskCalibrationApi;
|
||||
}
|
||||
|
||||
function openModal(options) {
|
||||
const api = ensureModal(options);
|
||||
const panel = api.modal.querySelector(".kiosk-calibration-panel");
|
||||
if (panel && options && options.theme) {
|
||||
const dark = options.theme === "dark";
|
||||
panel.classList.toggle("kiosk-calibration-panel--dark", dark);
|
||||
panel.classList.toggle("kiosk-calibration-panel--light", !dark);
|
||||
}
|
||||
api.openModal();
|
||||
return api;
|
||||
}
|
||||
|
||||
function setup(opts) {
|
||||
const options = opts || {};
|
||||
const linkSel = options.menuLinkSelector || "#calibrationLink";
|
||||
|
||||
if (typeof window.WespKioskMenu !== "undefined" && typeof window.WespKioskMenu.ensureOverlay === "function") {
|
||||
window.WespKioskMenu.ensureOverlay();
|
||||
}
|
||||
|
||||
const link = document.querySelector(linkSel);
|
||||
if (!link) {
|
||||
console.warn(LOG, "ссылка меню не найдена после ensureOverlay:", linkSel);
|
||||
return {};
|
||||
}
|
||||
|
||||
const api = ensureModal(options);
|
||||
|
||||
if (link.dataset.wespCalibrationBound === "1") {
|
||||
return { openModal: api.openModal, closeModal: api.closeModal };
|
||||
}
|
||||
link.dataset.wespCalibrationBound = "1";
|
||||
|
||||
link.addEventListener(
|
||||
"click",
|
||||
function (e) {
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey || e.button !== 0) return;
|
||||
if (window.frameElement) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
api.openModal();
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
return { openModal: api.openModal, closeModal: api.closeModal };
|
||||
}
|
||||
|
||||
window.WespKioskCalibration = { setup, ensureModal, openModal };
|
||||
})();
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Модальное окно «Проверка БД» в стиле киоска (как привязка терминала):
|
||||
* тёмная рамка, iframe с /db_check?embed=1.
|
||||
*/
|
||||
(function () {
|
||||
const LOG = "[WespKioskDbCheck]";
|
||||
const MENU_OVERLAY_ID = "wespKioskMenuOverlay";
|
||||
|
||||
function createModal() {
|
||||
const modal = document.createElement("div");
|
||||
modal.id = "kioskDbCheckModal";
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
modal.style.cssText =
|
||||
"display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:5000;" +
|
||||
"align-items:center;justify-content:center;padding:12px;box-sizing:border-box;";
|
||||
modal.innerHTML = `
|
||||
<div class="kiosk-db-check-panel" style="
|
||||
background:#1f1f1f;
|
||||
color:#fff;
|
||||
border-radius:14px;
|
||||
border:1px solid #2f2f2f;
|
||||
box-shadow:0 0 20px rgba(0,0,0,.45);
|
||||
width:min(920px,100%);
|
||||
max-height:min(92vh,900px);
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
overflow:hidden;
|
||||
">
|
||||
<div style="
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
gap:12px;
|
||||
padding:14px 16px;
|
||||
border-bottom:1px solid #2f2f2f;
|
||||
flex-shrink:0;
|
||||
">
|
||||
<h2 style="margin:0;font-size:1.125rem;font-weight:600;line-height:1.3">Проверка баз данных</h2>
|
||||
<button type="button" id="kioskDbCheckClose" style="
|
||||
padding:10px 16px;
|
||||
border-radius:8px;
|
||||
border:1px solid #7a7a7a;
|
||||
background:#3a3a3a;
|
||||
color:#fff;
|
||||
cursor:pointer;
|
||||
font-size:15px;
|
||||
flex-shrink:0;
|
||||
">Закрыть</button>
|
||||
</div>
|
||||
<iframe
|
||||
id="kioskDbCheckFrame"
|
||||
title="Проверка баз данных"
|
||||
style="border:0;width:100%;flex:1;min-height:min(70vh,560px);background:#f9fafb;"
|
||||
></iframe>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const iframe = modal.querySelector("#kioskDbCheckFrame");
|
||||
const closeBtn = modal.querySelector("#kioskDbCheckClose");
|
||||
const panel = modal.querySelector(".kiosk-db-check-panel");
|
||||
|
||||
function closeModal() {
|
||||
modal.style.display = "none";
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
try {
|
||||
iframe.src = "about:blank";
|
||||
} catch (e) {
|
||||
console.warn(LOG, "iframe reset", e);
|
||||
}
|
||||
document.removeEventListener("keydown", onKey);
|
||||
}
|
||||
|
||||
function onKey(e) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
const menu = document.getElementById(MENU_OVERLAY_ID);
|
||||
if (menu) menu.style.display = "none";
|
||||
|
||||
iframe.src = "/db_check?embed=1";
|
||||
modal.style.display = "flex";
|
||||
modal.setAttribute("aria-hidden", "false");
|
||||
document.addEventListener("keydown", onKey);
|
||||
}
|
||||
|
||||
closeBtn.addEventListener("click", closeModal);
|
||||
modal.addEventListener("click", function (e) {
|
||||
if (e.target === modal) closeModal();
|
||||
});
|
||||
if (panel) {
|
||||
panel.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
}
|
||||
|
||||
return { modal, openModal, closeModal };
|
||||
}
|
||||
|
||||
function setup(opts) {
|
||||
const options = opts || {};
|
||||
const linkSel = options.menuLinkSelector || "#dbCheckLink";
|
||||
|
||||
if (typeof window.WespKioskMenu !== "undefined" && typeof window.WespKioskMenu.ensureOverlay === "function") {
|
||||
window.WespKioskMenu.ensureOverlay();
|
||||
}
|
||||
|
||||
const link = document.querySelector(linkSel);
|
||||
if (!link) {
|
||||
console.warn(LOG, "ссылка меню не найдена после ensureOverlay:", linkSel);
|
||||
return {};
|
||||
}
|
||||
|
||||
let api;
|
||||
if (!window.__wespKioskDbCheckApi) {
|
||||
api = createModal();
|
||||
window.__wespKioskDbCheckApi = api;
|
||||
} else {
|
||||
api = window.__wespKioskDbCheckApi;
|
||||
}
|
||||
|
||||
if (link.dataset.wespDbCheckBound === "1") {
|
||||
return { openModal: api.openModal, closeModal: api.closeModal };
|
||||
}
|
||||
link.dataset.wespDbCheckBound = "1";
|
||||
|
||||
link.addEventListener(
|
||||
"click",
|
||||
function (e) {
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey || e.button !== 0) return;
|
||||
if (window.frameElement) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
api.openModal();
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
return { openModal: api.openModal, closeModal: api.closeModal };
|
||||
}
|
||||
|
||||
window.WespKioskDbCheck = { setup };
|
||||
})();
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Диалоги для страниц киоска / разгрузки / отбора (терминал). Не подключать на зоотех-оболочку — там wesp-dialog.js (WespDialog).
|
||||
* API: WespKioskDialog.alert(msg, opts?) -> Promise<void>
|
||||
* WespKioskDialog.confirm(msg, opts?) -> Promise<boolean>
|
||||
* opts.timerOnly — закрытие только через WespKioskDialog.closeActive() (таймер); без кнопки, без Escape и клика по фону.
|
||||
*/
|
||||
(function (global) {
|
||||
var OVERLAY_ID = "wespKioskDialogOverlay";
|
||||
var STYLE_ID = "wesp-kiosk-dialog-styles";
|
||||
|
||||
function injectStyles() {
|
||||
if (document.getElementById(STYLE_ID)) return;
|
||||
var s = document.createElement("style");
|
||||
s.id = STYLE_ID;
|
||||
s.textContent =
|
||||
"#" +
|
||||
OVERLAY_ID +
|
||||
"{" +
|
||||
"position:fixed;inset:0;z-index:5000;" +
|
||||
"display:none;align-items:center;justify-content:center;" +
|
||||
"padding:16px;background:rgba(0,0,0,.55);" +
|
||||
"box-sizing:border-box;" +
|
||||
"}" +
|
||||
"#" +
|
||||
OVERLAY_ID +
|
||||
'[aria-hidden="false"]{display:flex;}' +
|
||||
".wesp-kiosk-dialog{" +
|
||||
"width:min(420px,100%);max-height:min(80vh,520px);overflow:auto;" +
|
||||
"background:#fff;color:#111827;border-radius:8px;" +
|
||||
"border:1px solid #e5e7eb;box-shadow:0 8px 32px rgba(0,0,0,.18);" +
|
||||
"padding:20px 22px;font-family:Inter,system-ui,sans-serif;" +
|
||||
"text-align:left;" +
|
||||
"}" +
|
||||
".wesp-kiosk-dialog-title{" +
|
||||
"margin:0 0 12px 0;font-size:1.125rem;font-weight:600;color:#111827;" +
|
||||
"}" +
|
||||
".wesp-kiosk-dialog-body{" +
|
||||
"margin:0;font-size:0.95rem;line-height:1.5;color:#374151;white-space:pre-wrap;word-break:break-word;" +
|
||||
"}" +
|
||||
".wesp-kiosk-dialog-actions{" +
|
||||
"display:flex;justify-content:flex-end;flex-wrap:wrap;gap:10px;margin-top:20px;" +
|
||||
"}" +
|
||||
".wesp-kiosk-dialog-btn{" +
|
||||
"min-width:120px;padding:10px 18px;border-radius:6px;font-size:0.95rem;font-weight:500;" +
|
||||
"cursor:pointer;font-family:inherit;border:1px solid #e5e7eb;background:#fff;color:#374151;" +
|
||||
"transition:background .2s,border-color .2s,box-shadow .2s;" +
|
||||
"}" +
|
||||
".wesp-kiosk-dialog-btn:hover{filter:brightness(.97);}" +
|
||||
".wesp-kiosk-dialog-btn-primary{" +
|
||||
"background:#2563eb;border-color:#2563eb;color:#fff;" +
|
||||
"}" +
|
||||
".wesp-kiosk-dialog-btn-primary:hover{box-shadow:0 4px 12px rgba(37,99,235,.25);}" +
|
||||
".wesp-kiosk-dialog-btn-danger{" +
|
||||
"background:#ef4444;border-color:#ef4444;color:#fff;" +
|
||||
"}" +
|
||||
".wesp-kiosk-dialog--danger{border-top:4px solid #ef4444;}" +
|
||||
".wesp-kiosk-dialog--success{border-top:4px solid #10b981;}" +
|
||||
"html[data-theme=dark] #" +
|
||||
OVERLAY_ID +
|
||||
" .wesp-kiosk-dialog{" +
|
||||
"background:#242424;color:#e6e6e6;border-color:#3a3a3a;" +
|
||||
"}" +
|
||||
"html[data-theme=dark] #" +
|
||||
OVERLAY_ID +
|
||||
" .wesp-kiosk-dialog-title{color:#f3f4f6;}" +
|
||||
"html[data-theme=dark] #" +
|
||||
OVERLAY_ID +
|
||||
" .wesp-kiosk-dialog-body{color:#d1d5db;}" +
|
||||
"html[data-theme=dark] #" +
|
||||
OVERLAY_ID +
|
||||
" .wesp-kiosk-dialog-btn{" +
|
||||
"background:#2d2d2d;border-color:#3a3a3a;color:#e6e6e6;" +
|
||||
"}" +
|
||||
"html[data-theme=dark] #" +
|
||||
OVERLAY_ID +
|
||||
" .wesp-kiosk-dialog-btn-primary{background:#2563eb;border-color:#2563eb;color:#fff;}" +
|
||||
"html[data-theme=dark] #" +
|
||||
OVERLAY_ID +
|
||||
" .wesp-kiosk-dialog-btn-danger{background:#dc2626;border-color:#dc2626;color:#fff;}" +
|
||||
"";
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
function ensureDom() {
|
||||
injectStyles();
|
||||
var el = document.getElementById(OVERLAY_ID);
|
||||
if (el) return el;
|
||||
el = document.createElement("div");
|
||||
el.id = OVERLAY_ID;
|
||||
el.setAttribute("aria-hidden", "true");
|
||||
el.innerHTML =
|
||||
'<div class="wesp-kiosk-dialog" role="dialog" aria-modal="true" aria-labelledby="wespKioskDialogTitle">' +
|
||||
'<h2 class="wesp-kiosk-dialog-title" id="wespKioskDialogTitle"></h2>' +
|
||||
'<p class="wesp-kiosk-dialog-body" id="wespKioskDialogBody"></p>' +
|
||||
'<div class="wesp-kiosk-dialog-actions" id="wespKioskDialogActions"></div>' +
|
||||
"</div>";
|
||||
el.addEventListener("click", function (e) {
|
||||
if (e.target === el) {
|
||||
backdropDismiss();
|
||||
}
|
||||
});
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
var pendingResolve = null;
|
||||
var pendingMode = null;
|
||||
/** true: alert с opts.timerOnly — пользователь не закрывает окно вручную */
|
||||
var blockUserDismiss = false;
|
||||
|
||||
function backdropDismiss() {
|
||||
if (blockUserDismiss) return;
|
||||
if (pendingMode === "confirm" && pendingResolve) {
|
||||
var fn = pendingResolve;
|
||||
pendingResolve = null;
|
||||
pendingMode = null;
|
||||
hide();
|
||||
fn(false);
|
||||
} else if (pendingMode === "alert" && pendingResolve) {
|
||||
var fn2 = pendingResolve;
|
||||
pendingResolve = null;
|
||||
pendingMode = null;
|
||||
hide();
|
||||
fn2();
|
||||
}
|
||||
}
|
||||
|
||||
function hide() {
|
||||
var el = document.getElementById(OVERLAY_ID);
|
||||
if (!el) return;
|
||||
var dlg = el.querySelector(".wesp-kiosk-dialog");
|
||||
if (dlg) dlg.removeAttribute("tabindex");
|
||||
blockUserDismiss = false;
|
||||
el.setAttribute("aria-hidden", "true");
|
||||
document.removeEventListener("keydown", onKey);
|
||||
}
|
||||
|
||||
/** Закрыть активный alert/confirm без клика (таймер, программное закрытие). */
|
||||
function closeActive() {
|
||||
if (pendingMode === "confirm" && pendingResolve) {
|
||||
var fnC = pendingResolve;
|
||||
pendingResolve = null;
|
||||
pendingMode = null;
|
||||
hide();
|
||||
fnC(false);
|
||||
} else if (pendingMode === "alert" && pendingResolve) {
|
||||
var fnA = pendingResolve;
|
||||
pendingResolve = null;
|
||||
pendingMode = null;
|
||||
hide();
|
||||
fnA();
|
||||
}
|
||||
}
|
||||
|
||||
function onKey(e) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
if (blockUserDismiss) return;
|
||||
backdropDismiss();
|
||||
}
|
||||
}
|
||||
|
||||
function show() {
|
||||
var el = document.getElementById(OVERLAY_ID);
|
||||
if (!el) return;
|
||||
el.setAttribute("aria-hidden", "false");
|
||||
document.addEventListener("keydown", onKey);
|
||||
}
|
||||
|
||||
function setDialogVariant(dialogEl, variant) {
|
||||
dialogEl.classList.remove("wesp-kiosk-dialog--danger", "wesp-kiosk-dialog--success");
|
||||
if (variant === "danger") dialogEl.classList.add("wesp-kiosk-dialog--danger");
|
||||
else if (variant === "success") dialogEl.classList.add("wesp-kiosk-dialog--success");
|
||||
}
|
||||
|
||||
function displayDialogMessage(message, opts) {
|
||||
const variant = opts && opts.variant;
|
||||
const fallback = opts && opts.fallback;
|
||||
if (variant === "danger" && global.WespUserMessages?.userFacingMessage) {
|
||||
return global.WespUserMessages.userFacingMessage(message, fallback || "Не удалось выполнить операцию. Попробуйте ещё раз.");
|
||||
}
|
||||
return message == null ? "" : String(message);
|
||||
}
|
||||
|
||||
function alert(message, opts) {
|
||||
opts = opts || {};
|
||||
return new Promise(function (resolve) {
|
||||
var overlay = ensureDom();
|
||||
var dialog = overlay.querySelector(".wesp-kiosk-dialog");
|
||||
var titleEl = document.getElementById("wespKioskDialogTitle");
|
||||
var bodyEl = document.getElementById("wespKioskDialogBody");
|
||||
var actions = document.getElementById("wespKioskDialogActions");
|
||||
titleEl.textContent = opts.title != null ? String(opts.title) : "Сообщение";
|
||||
bodyEl.textContent = displayDialogMessage(message, opts);
|
||||
setDialogVariant(dialog, opts.variant || "info");
|
||||
actions.innerHTML = "";
|
||||
blockUserDismiss = !!opts.timerOnly;
|
||||
pendingMode = "alert";
|
||||
pendingResolve = resolve;
|
||||
if (!opts.timerOnly) {
|
||||
var ok = document.createElement("button");
|
||||
ok.type = "button";
|
||||
ok.className = "wesp-kiosk-dialog-btn wesp-kiosk-dialog-btn-primary";
|
||||
ok.textContent = opts.okText || "Понятно";
|
||||
ok.addEventListener("click", function () {
|
||||
pendingResolve = null;
|
||||
pendingMode = null;
|
||||
hide();
|
||||
resolve();
|
||||
});
|
||||
actions.appendChild(ok);
|
||||
show();
|
||||
ok.focus();
|
||||
} else {
|
||||
dialog.setAttribute("tabindex", "-1");
|
||||
show();
|
||||
dialog.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function confirm(message, opts) {
|
||||
opts = opts || {};
|
||||
return new Promise(function (resolve) {
|
||||
blockUserDismiss = false;
|
||||
var overlay = ensureDom();
|
||||
var dialog = overlay.querySelector(".wesp-kiosk-dialog");
|
||||
var titleEl = document.getElementById("wespKioskDialogTitle");
|
||||
var bodyEl = document.getElementById("wespKioskDialogBody");
|
||||
var actions = document.getElementById("wespKioskDialogActions");
|
||||
titleEl.textContent = opts.title != null ? String(opts.title) : "Подтверждение";
|
||||
bodyEl.textContent = message == null ? "" : String(message);
|
||||
setDialogVariant(dialog, opts.variant || "info");
|
||||
actions.innerHTML = "";
|
||||
var cancel = document.createElement("button");
|
||||
cancel.type = "button";
|
||||
cancel.className = "wesp-kiosk-dialog-btn";
|
||||
cancel.textContent = opts.cancelText || "Отмена";
|
||||
var ok = document.createElement("button");
|
||||
ok.type = "button";
|
||||
ok.className =
|
||||
"wesp-kiosk-dialog-btn " +
|
||||
(opts.danger ? "wesp-kiosk-dialog-btn-danger" : "wesp-kiosk-dialog-btn-primary");
|
||||
ok.textContent = opts.okText || "Да";
|
||||
cancel.addEventListener("click", function () {
|
||||
pendingResolve = null;
|
||||
pendingMode = null;
|
||||
hide();
|
||||
resolve(false);
|
||||
});
|
||||
ok.addEventListener("click", function () {
|
||||
pendingResolve = null;
|
||||
pendingMode = null;
|
||||
hide();
|
||||
resolve(true);
|
||||
});
|
||||
actions.appendChild(cancel);
|
||||
actions.appendChild(ok);
|
||||
pendingMode = "confirm";
|
||||
pendingResolve = resolve;
|
||||
show();
|
||||
ok.focus();
|
||||
});
|
||||
}
|
||||
|
||||
global.WespKioskDialog = {
|
||||
alert: alert,
|
||||
confirm: confirm,
|
||||
closeActive: closeActive,
|
||||
};
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
@@ -0,0 +1,169 @@
|
||||
(function () {
|
||||
const STYLE_ID = "wespKioskMenuStyles";
|
||||
const OVERLAY_ID = "wespKioskMenuOverlay";
|
||||
|
||||
function ensureStyles() {
|
||||
if (document.getElementById(STYLE_ID)) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = `
|
||||
#${OVERLAY_ID} {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
z-index: 4300;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
#${OVERLAY_ID} .wesp-kiosk-menu-panel {
|
||||
width: min(420px, 96vw);
|
||||
background: #1f1f1f;
|
||||
border: 1px solid #2f2f2f;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 0 24px rgba(0, 0, 0, 0.45);
|
||||
color: #fff;
|
||||
padding: 16px;
|
||||
}
|
||||
#${OVERLAY_ID} .wesp-kiosk-menu-logo-wrap {
|
||||
text-align: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
#${OVERLAY_ID} .wesp-kiosk-menu-logo {
|
||||
height: 32px;
|
||||
width: auto;
|
||||
}
|
||||
#${OVERLAY_ID} .wesp-kiosk-menu-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
#${OVERLAY_ID} .wesp-kiosk-menu-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: #fff;
|
||||
background: #2b2b2b;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
font-size: 15px;
|
||||
}
|
||||
#${OVERLAY_ID} .wesp-kiosk-menu-link:hover {
|
||||
background: #1b66a8;
|
||||
border-color: #4ea8ff;
|
||||
}
|
||||
#${OVERLAY_ID} .wesp-kiosk-menu-close {
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
border: 1px solid #7a7a7a;
|
||||
border-radius: 10px;
|
||||
background: #3a3a3a;
|
||||
color: #fff;
|
||||
padding: 10px 12px;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
#${OVERLAY_ID} .wesp-kiosk-menu-close:hover {
|
||||
background: #505050;
|
||||
}
|
||||
.wesp-kiosk-menu-button {
|
||||
position: fixed !important;
|
||||
top: 16px !important;
|
||||
right: 16px !important;
|
||||
z-index: 4250 !important;
|
||||
width: 56px !important;
|
||||
height: 56px !important;
|
||||
border-radius: 50% !important;
|
||||
border: 1px solid #1f6fd6 !important;
|
||||
background: #1f6fd6 !important;
|
||||
color: #fff !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
padding: 0 !important;
|
||||
cursor: pointer !important;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35) !important;
|
||||
font-size: 30px !important;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
.wesp-kiosk-menu-button:hover {
|
||||
background: #1859a8 !important;
|
||||
transform: scale(1.04);
|
||||
}
|
||||
.wesp-kiosk-menu-button img {
|
||||
display: none !important;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.wesp-kiosk-menu-button {
|
||||
top: 12px !important;
|
||||
right: 12px !important;
|
||||
width: 50px !important;
|
||||
height: 50px !important;
|
||||
font-size: 28px !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function ensureOverlay() {
|
||||
ensureStyles();
|
||||
let overlay = document.getElementById(OVERLAY_ID);
|
||||
if (overlay) return overlay;
|
||||
|
||||
overlay = document.createElement("div");
|
||||
overlay.id = OVERLAY_ID;
|
||||
overlay.innerHTML = `
|
||||
<div class="wesp-kiosk-menu-panel" role="dialog" aria-modal="true" aria-label="Меню киоска">
|
||||
<div class="wesp-kiosk-menu-logo-wrap">
|
||||
<img src="/static/logo2.png" alt="WESP" class="wesp-kiosk-menu-logo">
|
||||
</div>
|
||||
<div class="wesp-kiosk-menu-links">
|
||||
<a class="wesp-kiosk-menu-link" href="/scales">Главная</a>
|
||||
<a class="wesp-kiosk-menu-link" href="#" id="calibrationLink">Калибровка</a>
|
||||
<a class="wesp-kiosk-menu-link" href="#" id="dbCheckLink">Проверка БД</a>
|
||||
<a class="wesp-kiosk-menu-link" href="#" id="pairTerminalLink">Привязать терминал</a>
|
||||
</div>
|
||||
<button type="button" class="wesp-kiosk-menu-close" id="wespKioskMenuClose">Закрыть</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const panel = overlay.querySelector(".wesp-kiosk-menu-panel");
|
||||
const closeBtn = overlay.querySelector("#wespKioskMenuClose");
|
||||
const close = () => {
|
||||
overlay.style.display = "none";
|
||||
};
|
||||
closeBtn.addEventListener("click", close);
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (e.target === overlay) close();
|
||||
});
|
||||
panel.addEventListener("click", (e) => e.stopPropagation());
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function setup(opts) {
|
||||
const options = opts || {};
|
||||
const selector =
|
||||
options.menuButtonSelector || "#kioskMenuButton, .kiosk-menu-btn, .nav-button";
|
||||
const menuButton = document.querySelector(selector);
|
||||
if (!menuButton) return;
|
||||
|
||||
ensureStyles();
|
||||
const overlay = ensureOverlay();
|
||||
if (menuButton.dataset.wespMenuBound === "1") return;
|
||||
menuButton.classList.add("wesp-kiosk-menu-button");
|
||||
menuButton.setAttribute("title", "Меню");
|
||||
menuButton.setAttribute("aria-label", "Открыть меню");
|
||||
menuButton.textContent = "☰";
|
||||
menuButton.dataset.wespMenuBound = "1";
|
||||
menuButton.removeAttribute("onclick");
|
||||
menuButton.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
overlay.style.display = "flex";
|
||||
});
|
||||
}
|
||||
|
||||
window.WespKioskMenu = { setup, ensureOverlay };
|
||||
})();
|
||||
@@ -0,0 +1,285 @@
|
||||
(function () {
|
||||
const LOG = "[WespKioskPairing]";
|
||||
const API = {
|
||||
status: "/api/kiosk/status",
|
||||
accessLink: "/api/kiosk/access-link",
|
||||
setupAvailable: "/api/kiosk/setup-available",
|
||||
};
|
||||
const LOCALHOST_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
||||
|
||||
function isLocalhostContext() {
|
||||
return LOCALHOST_HOSTS.has((window.location.hostname || "").toLowerCase());
|
||||
}
|
||||
|
||||
function ensureModalStyles() {
|
||||
const criticalId = "wespKioskPairCriticalCss";
|
||||
if (!document.getElementById(criticalId)) {
|
||||
const style = document.createElement("style");
|
||||
style.id = criticalId;
|
||||
style.textContent =
|
||||
"#kioskPairModal{display:none;position:fixed;inset:0;z-index:5000;padding:8px;background:rgba(0,0,0,.55);overflow:hidden}" +
|
||||
"#kioskPairModal.kiosk-pair-open{display:flex;align-items:stretch;justify-content:center;height:100vh;height:100dvh}" +
|
||||
"body.kiosk-pair-modal-open{overflow:hidden!important}" +
|
||||
"#kioskPairModal .kiosk-pair-panel{display:flex;flex-direction:column;width:min(480px,100%);height:100%;max-height:100%;min-height:0;overflow:hidden;box-sizing:border-box}" +
|
||||
"#kioskPairModal .kiosk-pair-body{flex:1 1 auto;min-height:0;overflow-y:auto;-webkit-overflow-scrolling:touch}" +
|
||||
"#kioskPairModal .kiosk-pair-actions{flex:0 0 auto;display:flex;flex-wrap:wrap;gap:6px}";
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
if (document.getElementById("wespKioskModalsCss")) return;
|
||||
if (window.WespKioskTheme && typeof window.WespKioskTheme.ensureModalStyles === "function") {
|
||||
window.WespKioskTheme.ensureModalStyles();
|
||||
return;
|
||||
}
|
||||
const link = document.createElement("link");
|
||||
link.id = "wespKioskModalsCss";
|
||||
link.rel = "stylesheet";
|
||||
link.href = "/static/css/kiosk-modals.css";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
function createModal() {
|
||||
ensureModalStyles();
|
||||
const modal = document.createElement("div");
|
||||
modal.id = "kioskPairModal";
|
||||
modal.innerHTML = `
|
||||
<div class="kiosk-pair-panel" role="dialog" aria-modal="true" aria-labelledby="kioskPairTitle">
|
||||
<div class="kiosk-pair-header">
|
||||
<h3 id="kioskPairTitle">Привязать терминал</h3>
|
||||
<p class="kiosk-pair-subtitle">Start URL для Fully Kiosk Browser</p>
|
||||
</div>
|
||||
<div class="kiosk-pair-body">
|
||||
<div class="kiosk-pair-steps">
|
||||
1) Скопируйте ссылку или отсканируйте QR.<br/>
|
||||
2) Fully Kiosk → Settings → Start URL → вставьте ссылку → Save.<br/>
|
||||
3) Перезапустите Fully Kiosk.
|
||||
</div>
|
||||
<div id="kioskPairLoading" class="kiosk-pair-loading">
|
||||
<span class="kiosk-pair-spinner"></span>
|
||||
<span>Загрузка ссылки...</span>
|
||||
</div>
|
||||
<div class="kiosk-pair-qr-wrap">
|
||||
<img id="kioskPairQr" class="kiosk-pair-qr" alt="QR Start URL" />
|
||||
</div>
|
||||
<p id="kioskPairUrl" class="kiosk-pair-url"></p>
|
||||
<p id="kioskPairStatus" class="kiosk-pair-status"></p>
|
||||
</div>
|
||||
<div class="kiosk-pair-actions">
|
||||
<button id="kioskPairCopy" type="button" class="kiosk-pair-btn kiosk-pair-btn--primary">Скопировать ссылку</button>
|
||||
<button id="kioskPairRefresh" type="button" class="kiosk-pair-btn kiosk-pair-btn--secondary">Обновить ссылку</button>
|
||||
<button id="kioskPairClose" type="button" class="kiosk-pair-btn kiosk-pair-btn--ghost">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
return modal;
|
||||
}
|
||||
|
||||
function createBlockOverlay() {
|
||||
const el = document.createElement("div");
|
||||
el.id = "kioskBlockedOverlay";
|
||||
el.style.cssText =
|
||||
"display:none;position:fixed;inset:0;background:rgba(10,10,10,.78);z-index:4500;color:#fff;align-items:center;justify-content:center;text-align:center;padding:20px;";
|
||||
el.innerHTML = `
|
||||
<div style="max-width:520px">
|
||||
<h2 style="margin:0 0 10px 0">Терминал не привязан</h2>
|
||||
<p style="margin:0;line-height:1.4">Обратитесь к администратору для настройки терминала.</p>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
async function fetchJson(url, options) {
|
||||
const response = await fetch(url, options);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
console.warn(LOG, url, response.status, data.message || data);
|
||||
throw new Error(data.message || `HTTP ${response.status}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function setup(opts) {
|
||||
const options = opts || {};
|
||||
const modal = createModal();
|
||||
const block = createBlockOverlay();
|
||||
const qrImg = modal.querySelector("#kioskPairQr");
|
||||
const qrWrap = modal.querySelector(".kiosk-pair-qr-wrap");
|
||||
const loadingEl = modal.querySelector("#kioskPairLoading");
|
||||
const urlEl = modal.querySelector("#kioskPairUrl");
|
||||
const statusEl = modal.querySelector("#kioskPairStatus");
|
||||
const copyBtn = modal.querySelector("#kioskPairCopy");
|
||||
const refreshBtn = modal.querySelector("#kioskPairRefresh");
|
||||
const closeBtn = modal.querySelector("#kioskPairClose");
|
||||
let currentStartUrl = "";
|
||||
let canIssueAccessLink = isLocalhostContext();
|
||||
|
||||
function isEnforcePairedOnly(data) {
|
||||
if (!data || typeof data !== "object") return true;
|
||||
return data.enforce_paired_only !== false;
|
||||
}
|
||||
|
||||
async function refreshSetupAvailability() {
|
||||
if (isLocalhostContext()) {
|
||||
canIssueAccessLink = true;
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const data = await fetchJson(API.setupAvailable);
|
||||
canIssueAccessLink = !!data.can_issue_access_link;
|
||||
} catch (e) {
|
||||
console.warn(LOG, "setup-available error", e);
|
||||
canIssueAccessLink = false;
|
||||
}
|
||||
if (options.menuLinkSelector) {
|
||||
const menuLink = document.querySelector(options.menuLinkSelector);
|
||||
if (menuLink) {
|
||||
menuLink.style.display = canIssueAccessLink ? "" : "none";
|
||||
}
|
||||
}
|
||||
return canIssueAccessLink;
|
||||
}
|
||||
|
||||
async function checkStatus() {
|
||||
if (isLocalhostContext()) {
|
||||
block.style.display = "none";
|
||||
if (typeof options.onPaired === "function") options.onPaired({ paired: true, localhost: true });
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const data = await fetchJson(API.status);
|
||||
const enforce = isEnforcePairedOnly(data);
|
||||
const paired = !!data.paired || !enforce;
|
||||
console.debug(LOG, "/api/kiosk/status", {
|
||||
paired: !!data.paired,
|
||||
enforce_paired_only: enforce,
|
||||
device_id: data.device_id,
|
||||
status: data.status,
|
||||
});
|
||||
block.style.display = paired ? "none" : "flex";
|
||||
if (paired && typeof options.onPaired === "function") options.onPaired(data);
|
||||
if (!paired && typeof options.onUnpaired === "function") options.onUnpaired(data);
|
||||
return paired;
|
||||
} catch (e) {
|
||||
console.warn(LOG, "checkStatus error", e);
|
||||
block.style.display = "flex";
|
||||
if (typeof options.onUnpaired === "function") options.onUnpaired({});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAccessLink(refresh) {
|
||||
if (!canIssueAccessLink) {
|
||||
statusEl.textContent = "Выдача Start URL доступна только администратору или с localhost.";
|
||||
statusEl.style.color = "#ff9f9f";
|
||||
return;
|
||||
}
|
||||
loadingEl.style.display = "flex";
|
||||
qrWrap.classList.remove("kiosk-pair-qr-visible");
|
||||
qrImg.removeAttribute("src");
|
||||
statusEl.textContent = refresh ? "Обновление ссылки..." : "Загрузка ссылки...";
|
||||
try {
|
||||
const data = await fetchJson(API.accessLink, {
|
||||
method: refresh ? "POST" : "GET",
|
||||
headers: refresh ? { "Content-Type": "application/json" } : undefined,
|
||||
body: refresh ? JSON.stringify({ refresh: true }) : undefined,
|
||||
});
|
||||
currentStartUrl = data.start_url || data.pair_url || "";
|
||||
console.info(LOG, "access-link OK", { start_url: currentStartUrl.slice(0, 80) });
|
||||
const qrSrc = data.qr_image_url || "";
|
||||
await new Promise((resolve) => {
|
||||
const done = () => {
|
||||
qrImg.onload = null;
|
||||
qrImg.onerror = null;
|
||||
resolve();
|
||||
};
|
||||
qrImg.onload = done;
|
||||
qrImg.onerror = done;
|
||||
qrImg.src = qrSrc;
|
||||
});
|
||||
loadingEl.style.display = "none";
|
||||
qrWrap.classList.add("kiosk-pair-qr-visible");
|
||||
qrImg.style.display = "block";
|
||||
urlEl.textContent = currentStartUrl;
|
||||
statusEl.textContent = "Постоянная ссылка. Вставьте её в Fully Kiosk → Start URL.";
|
||||
statusEl.style.color = "";
|
||||
} catch (e) {
|
||||
loadingEl.style.display = "none";
|
||||
statusEl.textContent = globalThis.WespUserMessages?.userFacingMessage?.(e.message, "Не удалось получить ссылку") || "Не удалось получить ссылку";
|
||||
statusEl.style.color = "#ff9f9f";
|
||||
}
|
||||
}
|
||||
|
||||
async function openModal() {
|
||||
if (!canIssueAccessLink) {
|
||||
window.alert("Выдача Start URL доступна только администратору или при открытии с localhost.");
|
||||
return;
|
||||
}
|
||||
modal.classList.add("kiosk-pair-open");
|
||||
document.body.classList.add("kiosk-pair-modal-open");
|
||||
loadAccessLink(false);
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modal.classList.remove("kiosk-pair-open");
|
||||
document.body.classList.remove("kiosk-pair-modal-open");
|
||||
}
|
||||
|
||||
async function copyStartUrl() {
|
||||
if (!currentStartUrl) {
|
||||
statusEl.textContent = "Сначала дождитесь загрузки ссылки";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(currentStartUrl);
|
||||
} else {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = currentStartUrl;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.left = "-9999px";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
statusEl.textContent = "Ссылка скопирована";
|
||||
statusEl.style.color = "#9fd89f";
|
||||
} catch (e) {
|
||||
statusEl.textContent = "Не удалось скопировать — выделите ссылку вручную";
|
||||
statusEl.style.color = "#ff9f9f";
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshLink() {
|
||||
if (!window.confirm("Старая ссылка перестанет работать. Обновить Start URL?")) {
|
||||
return;
|
||||
}
|
||||
await loadAccessLink(true);
|
||||
}
|
||||
|
||||
copyBtn.addEventListener("click", copyStartUrl);
|
||||
refreshBtn.addEventListener("click", refreshLink);
|
||||
closeBtn.addEventListener("click", closeModal);
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === modal) closeModal();
|
||||
});
|
||||
|
||||
if (options.menuLinkSelector) {
|
||||
const menuLink = document.querySelector(options.menuLinkSelector);
|
||||
if (menuLink) {
|
||||
menuLink.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
openModal();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
refreshSetupAvailability().then(function () {
|
||||
checkStatus();
|
||||
});
|
||||
return { checkStatus, openModal };
|
||||
}
|
||||
|
||||
window.WespKioskPairing = { setup };
|
||||
})();
|
||||
@@ -0,0 +1,153 @@
|
||||
(function () {
|
||||
const KEY = "theme";
|
||||
const STYLE_ID = "wespKioskThemeStyles";
|
||||
const TOGGLE_ID = "wespThemeToggleWrap";
|
||||
|
||||
function savedTheme() {
|
||||
const t = (localStorage.getItem(KEY) || "").trim().toLowerCase();
|
||||
return t === "light" ? "light" : "dark";
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
const next = theme === "dark" ? "dark" : "light";
|
||||
document.documentElement.setAttribute("data-theme", next);
|
||||
localStorage.setItem(KEY, next);
|
||||
}
|
||||
|
||||
function injectModalChromeStyles() {
|
||||
const id = "wespKioskModalsCss";
|
||||
if (document.getElementById(id)) return;
|
||||
const link = document.createElement("link");
|
||||
link.id = id;
|
||||
link.rel = "stylesheet";
|
||||
link.href = "/static/css/kiosk-modals.css";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
function ensureModalStyles() {
|
||||
injectModalChromeStyles();
|
||||
}
|
||||
|
||||
function injectStyles() {
|
||||
if (document.getElementById(STYLE_ID)) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = `
|
||||
[data-theme="dark"] body {
|
||||
background: #1a1a1a !important;
|
||||
color: #e6e6e6 !important;
|
||||
}
|
||||
[data-theme="dark"] .card,
|
||||
[data-theme="dark"] .data-container,
|
||||
[data-theme="dark"] .admin-panel,
|
||||
[data-theme="dark"] .modal-content,
|
||||
[data-theme="dark"] .modal,
|
||||
[data-theme="dark"] .stat-card,
|
||||
[data-theme="dark"] .table-section,
|
||||
[data-theme="dark"] .loading-content,
|
||||
[data-theme="dark"] .error-modal {
|
||||
background: #242424 !important;
|
||||
color: #f0f0f0 !important;
|
||||
border-color: #3a3a3a !important;
|
||||
}
|
||||
[data-theme="dark"] .subtitle,
|
||||
[data-theme="dark"] .meta,
|
||||
[data-theme="dark"] .stat-label,
|
||||
[data-theme="dark"] .text-muted {
|
||||
color: #bdbdbd !important;
|
||||
}
|
||||
[data-theme="dark"] .btn,
|
||||
[data-theme="dark"] .control-button,
|
||||
[data-theme="dark"] .numpad-btn {
|
||||
color: #fff !important;
|
||||
}
|
||||
[data-theme="dark"] pre {
|
||||
background: #181818 !important;
|
||||
color: #dcdcdc !important;
|
||||
border: 1px solid #353535 !important;
|
||||
}
|
||||
#${TOGGLE_ID} {
|
||||
position: fixed;
|
||||
bottom: 12px;
|
||||
right: 16px;
|
||||
z-index: 4249;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
#${TOGGLE_ID} input {
|
||||
--s: 40px;
|
||||
height: var(--s);
|
||||
aspect-ratio: 2.5;
|
||||
width: auto;
|
||||
border-radius: var(--s);
|
||||
padding: calc(var(--s) / 10);
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
background:
|
||||
radial-gradient(farthest-side, #15202a 96%, #0000)
|
||||
var(--_p, 0%) / var(--s) content-box no-repeat,
|
||||
var(--_c, #ff7a7a);
|
||||
box-sizing: content-box;
|
||||
transform-origin: calc(3 * var(--s) / 5) 50%;
|
||||
transition:
|
||||
transform cubic-bezier(0, 300, 1, 300) .5s,
|
||||
background .3s .1s ease-in;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
#${TOGGLE_ID} input:checked {
|
||||
--_c: #85ff7a;
|
||||
--_p: 100%;
|
||||
transform-origin: calc(100% - 3 * var(--s) / 5) 50%;
|
||||
transform: rotate(0.1deg);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
#${TOGGLE_ID} {
|
||||
bottom: 12px;
|
||||
right: 12px;
|
||||
transform: scale(0.9);
|
||||
transform-origin: bottom right;
|
||||
}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function ensureToggle() {
|
||||
let wrap = document.getElementById(TOGGLE_ID);
|
||||
if (wrap) return wrap;
|
||||
wrap = document.createElement("button");
|
||||
wrap.id = TOGGLE_ID;
|
||||
wrap.type = "button";
|
||||
wrap.innerHTML = `<input id="wespThemeSwitch" type="checkbox" aria-label="Тёмная тема" title="Выключено — светлая тема. Включено — тёмная.">`;
|
||||
const input = wrap.querySelector("#wespThemeSwitch");
|
||||
input.addEventListener("change", function () {
|
||||
const next = input.checked ? "dark" : "light";
|
||||
applyTheme(next);
|
||||
});
|
||||
document.body.appendChild(wrap);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function setup(opts) {
|
||||
const options = opts || {};
|
||||
const theme = savedTheme();
|
||||
injectModalChromeStyles();
|
||||
injectStyles();
|
||||
applyTheme(theme);
|
||||
if (options.withToggle !== false) {
|
||||
const t = ensureToggle();
|
||||
const input = t.querySelector("#wespThemeSwitch");
|
||||
if (input) input.checked = theme === "dark";
|
||||
}
|
||||
}
|
||||
|
||||
window.WespKioskTheme = { setup, applyTheme, ensureModalStyles };
|
||||
})();
|
||||
@@ -72,11 +72,36 @@
|
||||
|
||||
function load(root, enterpriseId) {
|
||||
root.innerHTML = '<p class="text-muted small">Загрузка конфликтов…</p>';
|
||||
const orch = global.__WESP_ORCH__ || {};
|
||||
if (orch.configured === false) {
|
||||
root.innerHTML =
|
||||
'<p class="text-muted small">Orchestrator не настроен. Укажите upstream, hub_site_id и api_key в админке.</p>';
|
||||
return;
|
||||
}
|
||||
if (orch.reachable === false) {
|
||||
const host = orch.upstream ? ` (${orch.upstream})` : "";
|
||||
root.innerHTML =
|
||||
`<p class="text-warning small">Orchestrator недоступен${escapeHtml(host)}. Проверьте upstream URL и что сервис запущен.</p>`;
|
||||
return;
|
||||
}
|
||||
fetch(`${API}?enterprise_id=${encodeURIComponent(enterpriseId)}`)
|
||||
.then((r) => r.json())
|
||||
.then((items) => renderList(root, items || [], enterpriseId))
|
||||
.catch(() => {
|
||||
root.innerHTML = '<p class="text-danger small">Не удалось загрузить конфликты.</p>';
|
||||
.then((r) => {
|
||||
if (!r.ok) {
|
||||
return r.json().catch(() => ({})).then((body) => {
|
||||
throw new Error(body.message || `HTTP ${r.status}`);
|
||||
});
|
||||
}
|
||||
return r.json();
|
||||
})
|
||||
.then((items) => {
|
||||
if (items && items.error) {
|
||||
throw new Error(items.message || "Orchestrator error");
|
||||
}
|
||||
renderList(root, Array.isArray(items) ? items : [], enterpriseId);
|
||||
})
|
||||
.catch((err) => {
|
||||
const msg = err && err.message ? err.message : "Не удалось загрузить конфликты.";
|
||||
root.innerHTML = `<p class="text-danger small">${escapeHtml(msg)}</p>`;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -151,44 +151,11 @@ export function createRecipesAuthSettingsController({ notyf }) {
|
||||
}
|
||||
|
||||
function setupMobileMenu() {
|
||||
const hamburgerMenu = document.getElementById("hamburgerMenu");
|
||||
const mobileMenuOverlay = document.getElementById("mobileMenuOverlay");
|
||||
const closeMenu = document.getElementById("closeMenu");
|
||||
const mobileUserInfo = document.getElementById("mobileUserInfo");
|
||||
if (!hamburgerMenu || !mobileMenuOverlay || !closeMenu) return;
|
||||
|
||||
hamburgerMenu.addEventListener("click", () => {
|
||||
mobileMenuOverlay.classList.add("active");
|
||||
hamburgerMenu.classList.add("active");
|
||||
document.body.style.overflow = "hidden";
|
||||
const userInfo = document.getElementById("userInfo");
|
||||
if (userInfo && mobileUserInfo) {
|
||||
mobileUserInfo.textContent = userInfo.textContent || "";
|
||||
}
|
||||
});
|
||||
|
||||
const closeMobileMenu = () => {
|
||||
mobileMenuOverlay.classList.remove("active");
|
||||
hamburgerMenu.classList.remove("active");
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
|
||||
closeMenu.addEventListener("click", closeMobileMenu);
|
||||
mobileMenuOverlay.addEventListener("click", (e) => {
|
||||
if (e.target === mobileMenuOverlay) closeMobileMenu();
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && mobileMenuOverlay.classList.contains("active")) {
|
||||
closeMobileMenu();
|
||||
}
|
||||
});
|
||||
|
||||
const mobileMenuItems = document.querySelectorAll(".mobile-menu-item");
|
||||
mobileMenuItems.forEach((item) => {
|
||||
item.addEventListener("click", () => {
|
||||
setTimeout(closeMobileMenu, 300);
|
||||
});
|
||||
});
|
||||
const userInfo = document.getElementById("userInfo");
|
||||
if (userInfo && mobileUserInfo) {
|
||||
mobileUserInfo.textContent = userInfo.textContent?.trim() || "";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSyncClientsSettings() {
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
let activePreset = "day";
|
||||
let panelEl = null;
|
||||
let triggerEl = null;
|
||||
let isOpen = false;
|
||||
let suppressOutsideCloseUntil = 0;
|
||||
|
||||
function pad(n) {
|
||||
return String(n).padStart(2, "0");
|
||||
@@ -90,7 +92,7 @@
|
||||
|
||||
function setPresetActive(preset) {
|
||||
activePreset = preset || "";
|
||||
document.querySelectorAll("[data-drp-preset]").forEach((btn) => {
|
||||
panelEl?.querySelectorAll("[data-drp-preset]").forEach((btn) => {
|
||||
btn.classList.toggle("active", btn.getAttribute("data-drp-preset") === activePreset);
|
||||
});
|
||||
}
|
||||
@@ -131,7 +133,7 @@
|
||||
}
|
||||
|
||||
function positionPanel() {
|
||||
if (!triggerEl || !panelEl || panelEl.hidden) return;
|
||||
if (!triggerEl || !panelEl || !isOpen) return;
|
||||
const rect = triggerEl.getBoundingClientRect();
|
||||
const width = Math.max(rect.width, 304);
|
||||
let left = rect.left;
|
||||
@@ -149,28 +151,51 @@
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
if (panelEl) panelEl.hidden = true;
|
||||
isOpen = false;
|
||||
suppressOutsideCloseUntil = 0;
|
||||
if (panelEl) {
|
||||
panelEl.hidden = true;
|
||||
panelEl.setAttribute("hidden", "");
|
||||
panelEl.classList.remove("reports-drp__panel--open");
|
||||
panelEl.style.left = "";
|
||||
panelEl.style.top = "";
|
||||
panelEl.style.width = "";
|
||||
}
|
||||
if (triggerEl) triggerEl.setAttribute("aria-expanded", "false");
|
||||
window.removeEventListener("resize", positionPanel);
|
||||
window.removeEventListener("scroll", positionPanel, true);
|
||||
}
|
||||
|
||||
function isPanelTarget(target) {
|
||||
if (!target || !panelEl || !triggerEl) return false;
|
||||
return triggerEl.contains(target) || panelEl.contains(target);
|
||||
}
|
||||
|
||||
function openPanel() {
|
||||
const committed = readCommittedRange();
|
||||
draftFrom = committed.from || today();
|
||||
draftTo = committed.to || draftFrom;
|
||||
viewMonth = new Date(draftFrom.getFullYear(), draftFrom.getMonth(), 1);
|
||||
renderCalendar();
|
||||
if (panelEl) panelEl.hidden = false;
|
||||
global.WespReportsFilterDropdown?.closeAll?.();
|
||||
global.WespReportsExport?.close?.();
|
||||
isOpen = true;
|
||||
suppressOutsideCloseUntil = Date.now() + 180;
|
||||
if (panelEl) {
|
||||
panelEl.removeAttribute("hidden");
|
||||
panelEl.hidden = false;
|
||||
panelEl.classList.add("reports-drp__panel--open");
|
||||
}
|
||||
if (triggerEl) triggerEl.setAttribute("aria-expanded", "true");
|
||||
positionPanel();
|
||||
requestAnimationFrame(() => positionPanel());
|
||||
window.addEventListener("resize", positionPanel);
|
||||
window.addEventListener("scroll", positionPanel, true);
|
||||
}
|
||||
|
||||
function togglePanel() {
|
||||
if (panelEl?.hidden) openPanel();
|
||||
else closePanel();
|
||||
if (isOpen) closePanel();
|
||||
else openPanel();
|
||||
}
|
||||
|
||||
function inRange(d, a, b) {
|
||||
@@ -246,6 +271,8 @@
|
||||
triggerEl = document.getElementById("reportsDateRangeTrigger");
|
||||
panelEl = document.getElementById("reportsDateRangePanel");
|
||||
if (!triggerEl || !panelEl) return;
|
||||
if (triggerEl.dataset.drpWired === "1") return;
|
||||
triggerEl.dataset.drpWired = "1";
|
||||
|
||||
if (panelEl.parentElement !== document.body) {
|
||||
document.body.appendChild(panelEl);
|
||||
@@ -254,7 +281,7 @@
|
||||
panelEl.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
panelEl.addEventListener("click", (e) => e.stopPropagation());
|
||||
|
||||
document.querySelectorAll("[data-drp-preset]").forEach((btn) => {
|
||||
panelEl.querySelectorAll("[data-drp-preset]").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
setPeriod(btn.getAttribute("data-drp-preset") || "day");
|
||||
@@ -276,22 +303,36 @@
|
||||
positionPanel();
|
||||
});
|
||||
|
||||
triggerEl.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
togglePanel();
|
||||
});
|
||||
triggerEl.addEventListener(
|
||||
"pointerdown",
|
||||
(e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
togglePanel();
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
document.addEventListener("click", () => {
|
||||
if (!panelEl.hidden) closePanel();
|
||||
});
|
||||
document.addEventListener(
|
||||
"pointerdown",
|
||||
(e) => {
|
||||
if (Date.now() < suppressOutsideCloseUntil) return;
|
||||
if (!isOpen) return;
|
||||
if (isPanelTarget(e.target)) return;
|
||||
closePanel();
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") closePanel();
|
||||
if (e.key === "Escape" && isOpen) closePanel();
|
||||
});
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const highlightReportId = urlParams.get("report");
|
||||
if highlightReportId) {
|
||||
if (highlightReportId) {
|
||||
global.__wespHighlightReportId = highlightReportId;
|
||||
setPeriod("month");
|
||||
} else {
|
||||
@@ -305,6 +346,7 @@
|
||||
init,
|
||||
setPeriod,
|
||||
commit: commitDraft,
|
||||
close: closePanel,
|
||||
updateTriggerLabel,
|
||||
formatLabel,
|
||||
parseIso,
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
* Экспорт /reports — выбор раздела и формата (Excel / PDF).
|
||||
*/
|
||||
(function (global) {
|
||||
let btnEl = null;
|
||||
let menuEl = null;
|
||||
let isOpen = false;
|
||||
let suppressOutsideCloseUntil = 0;
|
||||
|
||||
function appendFilterContext(params) {
|
||||
const farms = global.WespReportsFilters?.getSelectedFarmNames?.() || [];
|
||||
const dispensers = global.WespReportsFilters?.getSelectedDispenserNames?.() || [];
|
||||
@@ -34,34 +39,89 @@
|
||||
window.location.href = buildExportUrl(format, section);
|
||||
}
|
||||
|
||||
function isMenuTarget(target) {
|
||||
if (!target || !btnEl || !menuEl) return false;
|
||||
return btnEl.contains(target) || menuEl.contains(target);
|
||||
}
|
||||
|
||||
function positionMenu() {
|
||||
if (!btnEl || !menuEl || !isOpen) return;
|
||||
const rect = btnEl.getBoundingClientRect();
|
||||
let top = rect.bottom + 6;
|
||||
const menuHeight = menuEl.offsetHeight || 320;
|
||||
if (top + menuHeight > window.innerHeight - 12) {
|
||||
top = Math.max(12, rect.top - menuHeight - 6);
|
||||
}
|
||||
menuEl.style.top = `${top}px`;
|
||||
menuEl.style.right = `${Math.max(12, window.innerWidth - rect.right)}px`;
|
||||
menuEl.style.left = "auto";
|
||||
menuEl.style.width = "";
|
||||
menuEl.style.transform = "";
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
const menu = document.getElementById("reportsExportMenu");
|
||||
const btn = document.getElementById("reportsExportBtn");
|
||||
if (menu) menu.hidden = true;
|
||||
if (btn) btn.setAttribute("aria-expanded", "false");
|
||||
isOpen = false;
|
||||
suppressOutsideCloseUntil = 0;
|
||||
if (menuEl) {
|
||||
menuEl.hidden = true;
|
||||
menuEl.setAttribute("hidden", "");
|
||||
menuEl.classList.remove("reports-export-dropdown__menu--open");
|
||||
menuEl.style.top = "";
|
||||
menuEl.style.right = "";
|
||||
menuEl.style.left = "";
|
||||
menuEl.style.width = "";
|
||||
menuEl.style.transform = "";
|
||||
}
|
||||
if (btnEl) btnEl.setAttribute("aria-expanded", "false");
|
||||
window.removeEventListener("resize", positionMenu);
|
||||
window.removeEventListener("scroll", positionMenu, true);
|
||||
}
|
||||
|
||||
function openMenu() {
|
||||
global.WespReportsDateRange?.close?.();
|
||||
global.WespReportsFilterDropdown?.closeAll?.();
|
||||
isOpen = true;
|
||||
suppressOutsideCloseUntil = Date.now() + 180;
|
||||
if (menuEl) {
|
||||
menuEl.removeAttribute("hidden");
|
||||
menuEl.hidden = false;
|
||||
menuEl.classList.add("reports-export-dropdown__menu--open");
|
||||
}
|
||||
if (btnEl) btnEl.setAttribute("aria-expanded", "true");
|
||||
positionMenu();
|
||||
requestAnimationFrame(() => positionMenu());
|
||||
window.addEventListener("resize", positionMenu);
|
||||
window.addEventListener("scroll", positionMenu, true);
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
const menu = document.getElementById("reportsExportMenu");
|
||||
const btn = document.getElementById("reportsExportBtn");
|
||||
if (!menu || !btn) return;
|
||||
const open = menu.hidden;
|
||||
menu.hidden = !open;
|
||||
btn.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
if (isOpen) closeMenu();
|
||||
else openMenu();
|
||||
}
|
||||
|
||||
function init() {
|
||||
const btn = document.getElementById("reportsExportBtn");
|
||||
const menu = document.getElementById("reportsExportMenu");
|
||||
if (!btn || !menu || btn.dataset.bound) return;
|
||||
btn.dataset.bound = "1";
|
||||
btnEl = document.getElementById("reportsExportBtn");
|
||||
menuEl = document.getElementById("reportsExportMenu");
|
||||
if (!btnEl || !menuEl || btnEl.dataset.bound) return;
|
||||
btnEl.dataset.bound = "1";
|
||||
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
toggleMenu();
|
||||
});
|
||||
if (menuEl.parentElement !== document.body) {
|
||||
document.body.appendChild(menuEl);
|
||||
}
|
||||
|
||||
menu.querySelectorAll("[data-reports-export-format]").forEach((item) => {
|
||||
btnEl.addEventListener(
|
||||
"pointerdown",
|
||||
(e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
toggleMenu();
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
menuEl.querySelectorAll("[data-reports-export-format]").forEach((item) => {
|
||||
item.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -73,14 +133,30 @@
|
||||
});
|
||||
});
|
||||
|
||||
menu.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
menu.addEventListener("click", (e) => e.stopPropagation());
|
||||
menuEl.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
menuEl.addEventListener("click", (e) => e.stopPropagation());
|
||||
|
||||
document.addEventListener(
|
||||
"pointerdown",
|
||||
(e) => {
|
||||
if (Date.now() < suppressOutsideCloseUntil) return;
|
||||
if (!isOpen) return;
|
||||
if (isMenuTarget(e.target)) return;
|
||||
closeMenu();
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
document.addEventListener("click", () => closeMenu());
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") closeMenu();
|
||||
if (e.key === "Escape" && isOpen) closeMenu();
|
||||
});
|
||||
}
|
||||
|
||||
global.WespReportsExport = { init, buildExportUrl, download, appendFilterContext };
|
||||
global.WespReportsExport = {
|
||||
init,
|
||||
buildExportUrl,
|
||||
download,
|
||||
appendFilterContext,
|
||||
close: closeMenu,
|
||||
};
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
||||
@@ -217,6 +217,7 @@
|
||||
trigger.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const willOpen = menu.hidden;
|
||||
global.WespReportsDateRange?.close?.();
|
||||
closeAll(willOpen ? menu : null);
|
||||
menu.hidden = !willOpen;
|
||||
trigger.setAttribute("aria-expanded", willOpen ? "true" : "false");
|
||||
@@ -250,11 +251,25 @@
|
||||
return api;
|
||||
}
|
||||
|
||||
function isReportsPopoverTarget(target) {
|
||||
if (!target) return false;
|
||||
const roots = [
|
||||
"reportsDateRangeTrigger",
|
||||
"reportsDateRangePanel",
|
||||
"reportsExportBtn",
|
||||
"reportsExportMenu",
|
||||
];
|
||||
return roots.some((id) => document.getElementById(id)?.contains(target));
|
||||
}
|
||||
|
||||
function init() {
|
||||
bindSelect("farm-select");
|
||||
bindSelect("dispenser-select");
|
||||
bindSelect("recipe-select");
|
||||
document.addEventListener("click", () => closeAll());
|
||||
document.addEventListener("click", (e) => {
|
||||
if (isReportsPopoverTarget(e.target)) return;
|
||||
closeAll();
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") closeAll();
|
||||
});
|
||||
@@ -278,5 +293,6 @@
|
||||
bindSelect,
|
||||
getMultiSelectValues,
|
||||
setMultiSelectValues,
|
||||
closeAll,
|
||||
};
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
||||
@@ -132,6 +132,15 @@
|
||||
|
||||
function buildAlertsParams() {
|
||||
const params = new URLSearchParams(getDateParams());
|
||||
const recipeIds = getRecipeIds();
|
||||
if (recipeIds.length === 1) {
|
||||
params.set("recipe_id", recipeIds[0]);
|
||||
return params;
|
||||
}
|
||||
if (recipeIds.length > 1) {
|
||||
params.set("recipe_ids", recipeIds.join(","));
|
||||
return params;
|
||||
}
|
||||
const dispenserIds = getDispenserIds();
|
||||
if (dispenserIds.length === 1) {
|
||||
params.set("dispenser_id", dispenserIds[0]);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/** Progress 0–100 for logo loader during setup wizard. */
|
||||
|
||||
const STEP_WEIGHTS = {
|
||||
theme: 10,
|
||||
welcome: 5,
|
||||
device: 12,
|
||||
network: 18,
|
||||
sync: 18,
|
||||
users: 22,
|
||||
hardware: 10,
|
||||
kiosk: 10,
|
||||
bootstrap: 25,
|
||||
done: 8,
|
||||
};
|
||||
|
||||
const FLOWS = {
|
||||
server: ["theme", "welcome", "device", "network", "users", "done"],
|
||||
client: ["theme", "welcome", "device", "sync", "hardware", "kiosk", "bootstrap", "done"],
|
||||
};
|
||||
|
||||
export function getFlow(deviceRole) {
|
||||
return FLOWS[deviceRole] || FLOWS.server;
|
||||
}
|
||||
|
||||
export function createSetupOrchestrator() {
|
||||
let deviceRole = "server";
|
||||
let currentStep = "welcome";
|
||||
let extraPercent = 0;
|
||||
|
||||
function flow() {
|
||||
return getFlow(deviceRole);
|
||||
}
|
||||
|
||||
function basePercent() {
|
||||
const steps = flow();
|
||||
const idx = steps.indexOf(currentStep);
|
||||
if (idx < 0) return 0;
|
||||
let total = 0;
|
||||
for (const s of steps) total += STEP_WEIGHTS[s] || 10;
|
||||
let done = 0;
|
||||
for (let i = 0; i < idx; i += 1) done += STEP_WEIGHTS[steps[i]] || 10;
|
||||
const curWeight = STEP_WEIGHTS[currentStep] || 10;
|
||||
return Math.min(100, Math.round(((done + curWeight * 0.35) / total) * 100));
|
||||
}
|
||||
|
||||
return {
|
||||
get percent() {
|
||||
return Math.min(100, Math.max(basePercent(), extraPercent));
|
||||
},
|
||||
get currentStep() {
|
||||
return currentStep;
|
||||
},
|
||||
get deviceRole() {
|
||||
return deviceRole;
|
||||
},
|
||||
setDeviceRole(role) {
|
||||
deviceRole = role;
|
||||
},
|
||||
setStep(step) {
|
||||
currentStep = step;
|
||||
extraPercent = 0;
|
||||
},
|
||||
bumpExtra(delta) {
|
||||
extraPercent = Math.min(100, extraPercent + delta);
|
||||
},
|
||||
setBootstrapProgress(p) {
|
||||
const steps = flow();
|
||||
const idx = steps.indexOf("bootstrap");
|
||||
if (idx < 0) return;
|
||||
let total = 0;
|
||||
let before = 0;
|
||||
for (let i = 0; i < steps.length; i += 1) {
|
||||
const w = STEP_WEIGHTS[steps[i]] || 10;
|
||||
if (i < idx) before += w;
|
||||
total += w;
|
||||
}
|
||||
const w = STEP_WEIGHTS.bootstrap || 25;
|
||||
extraPercent = Math.round(((before + w * Math.min(1, p)) / total) * 100);
|
||||
},
|
||||
flow,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* On-screen keyboard for setup wizard on touch kiosks (no system IME).
|
||||
* Uses wesp-setup CSS variables for light/dark theme.
|
||||
*/
|
||||
|
||||
const LAYOUTS = {
|
||||
hostname: [
|
||||
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
|
||||
["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"],
|
||||
[
|
||||
"a",
|
||||
"s",
|
||||
"d",
|
||||
"f",
|
||||
"g",
|
||||
"h",
|
||||
"j",
|
||||
"k",
|
||||
"l",
|
||||
{ key: "backspace", label: "⌫", span: 2 },
|
||||
],
|
||||
["z", "x", "c", "v", "b", "n", "m", "_", "-", "."],
|
||||
],
|
||||
url: [
|
||||
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
|
||||
["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"],
|
||||
[
|
||||
"a",
|
||||
"s",
|
||||
"d",
|
||||
"f",
|
||||
"g",
|
||||
"h",
|
||||
"j",
|
||||
"k",
|
||||
"l",
|
||||
{ key: "backspace", label: "⌫", span: 2 },
|
||||
],
|
||||
["z", "x", "c", "v", "b", "n", "m", "_", "-", "."],
|
||||
[
|
||||
{ key: "http://", label: "http://", span: 3 },
|
||||
{ key: ":", label: ":" },
|
||||
{ key: "/", label: "/" },
|
||||
{ key: "backspace", label: "⌫", span: 2 },
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
const HOSTNAME_CHAR = /[a-z0-9._-]/;
|
||||
const URL_CHAR = /[a-z0-9.:/_-]/;
|
||||
|
||||
function allowedChar(ch, layout) {
|
||||
if (layout === "url") return URL_CHAR.test(ch);
|
||||
return HOSTNAME_CHAR.test(ch);
|
||||
}
|
||||
|
||||
function normalizeInsert(ch, layout) {
|
||||
const lower = ch.toLowerCase();
|
||||
if (ch.length === 1 && !allowedChar(lower, layout)) return "";
|
||||
return lower;
|
||||
}
|
||||
|
||||
export class SetupTouchKeyboard {
|
||||
#panel;
|
||||
#root = null;
|
||||
#grid = null;
|
||||
#activeInput = null;
|
||||
#activeLayout = "hostname";
|
||||
#bindings = new Map();
|
||||
#docClickHandler = null;
|
||||
#maxLength = 200;
|
||||
|
||||
constructor(panel) {
|
||||
this.#panel = panel;
|
||||
}
|
||||
|
||||
attach(input, { layout = "hostname", maxLength = 200 } = {}) {
|
||||
if (!input || input.disabled) return;
|
||||
this.#bindings.set(input, { layout, maxLength });
|
||||
input.readOnly = true;
|
||||
input.inputMode = "none";
|
||||
input.autocomplete = "off";
|
||||
input.autocorrect = "off";
|
||||
input.spellcheck = false;
|
||||
input.classList.add("wesp-setup__input--touch");
|
||||
|
||||
const onFocus = () => this.#showFor(input);
|
||||
input.addEventListener("focus", onFocus);
|
||||
input.addEventListener("click", onFocus);
|
||||
}
|
||||
|
||||
mount() {
|
||||
if (this.#root) return;
|
||||
this.#root = document.createElement("div");
|
||||
this.#root.className = "wesp-setup__keyboard";
|
||||
this.#root.setAttribute("role", "group");
|
||||
this.#root.setAttribute("aria-label", "Экранная клавиатура");
|
||||
|
||||
this.#grid = document.createElement("div");
|
||||
this.#grid.className = "wesp-setup__keyboard-grid";
|
||||
this.#root.appendChild(this.#grid);
|
||||
|
||||
const footer = this.#panel.querySelector(".wesp-setup__footer");
|
||||
if (footer) {
|
||||
this.#panel.insertBefore(this.#root, footer);
|
||||
} else {
|
||||
this.#panel.appendChild(this.#root);
|
||||
}
|
||||
|
||||
this.#docClickHandler = (e) => {
|
||||
if (!this.#root?.classList.contains("is-visible")) return;
|
||||
const t = e.target;
|
||||
if (this.#root.contains(t)) return;
|
||||
if (this.#activeInput && (t === this.#activeInput || this.#activeInput.contains?.(t))) return;
|
||||
for (const input of this.#bindings.keys()) {
|
||||
if (t === input || input.contains?.(t)) return;
|
||||
}
|
||||
this.hide();
|
||||
};
|
||||
document.addEventListener("pointerdown", this.#docClickHandler, true);
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.#root?.classList.remove("is-visible");
|
||||
this.#activeInput = null;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.#docClickHandler) {
|
||||
document.removeEventListener("pointerdown", this.#docClickHandler, true);
|
||||
this.#docClickHandler = null;
|
||||
}
|
||||
this.#root?.remove();
|
||||
this.#root = null;
|
||||
this.#grid = null;
|
||||
this.#bindings.clear();
|
||||
this.#activeInput = null;
|
||||
}
|
||||
|
||||
#showFor(input) {
|
||||
const binding = this.#bindings.get(input);
|
||||
if (!binding) return;
|
||||
this.mount();
|
||||
this.#activeInput = input;
|
||||
this.#activeLayout = binding.layout;
|
||||
this.#maxLength = binding.maxLength;
|
||||
this.#renderKeys(binding.layout);
|
||||
this.#root.classList.add("is-visible");
|
||||
window.requestAnimationFrame(() => {
|
||||
input.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
});
|
||||
}
|
||||
|
||||
#renderKeys(layoutName) {
|
||||
const rows = LAYOUTS[layoutName] || LAYOUTS.hostname;
|
||||
this.#grid.replaceChildren();
|
||||
rows.forEach((row) => {
|
||||
row.forEach((item) => {
|
||||
const spec = typeof item === "string" ? { key: item, label: item } : item;
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "wesp-setup__keyboard-key";
|
||||
if (spec.key === "backspace") btn.classList.add("wesp-setup__keyboard-key--wide");
|
||||
if (spec.key === "http://") btn.classList.add("wesp-setup__keyboard-key--action");
|
||||
btn.textContent = spec.label || spec.key;
|
||||
const span = spec.span || 1;
|
||||
if (span > 1) btn.style.gridColumn = `span ${span}`;
|
||||
btn.addEventListener("pointerdown", (e) => {
|
||||
e.preventDefault();
|
||||
this.#onKey(spec.key);
|
||||
});
|
||||
this.#grid.appendChild(btn);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#onKey(key) {
|
||||
const input = this.#activeInput;
|
||||
if (!input) return;
|
||||
if (key === "backspace") {
|
||||
input.value = input.value.slice(0, -1);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
return;
|
||||
}
|
||||
let insert = "";
|
||||
if (key === "http://") {
|
||||
if (this.#activeLayout === "url") insert = "http://";
|
||||
} else {
|
||||
insert = normalizeInsert(key, this.#activeLayout);
|
||||
}
|
||||
if (!insert) return;
|
||||
if (input.value.length >= this.#maxLength) return;
|
||||
const start = input.selectionStart ?? input.value.length;
|
||||
const end = input.selectionEnd ?? start;
|
||||
input.value = input.value.slice(0, start) + insert + input.value.slice(end);
|
||||
const pos = start + insert.length;
|
||||
try {
|
||||
input.setSelectionRange(pos, pos);
|
||||
} catch {
|
||||
/* readonly selection may fail in some browsers */
|
||||
}
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* Диалоги alert/confirm для веб-оболочки зоотехника (не скрипты страниц киоска).
|
||||
* API: WespDialog.alert(msg, opts?) -> Promise<void>
|
||||
* WespDialog.confirm(msg, opts?) -> Promise<boolean>
|
||||
* opts.timerOnly — закрытие только через WespDialog.closeActive() (таймер); без кнопки, без Escape и клика по фону.
|
||||
*/
|
||||
(function (global) {
|
||||
var OVERLAY_ID = "wespShellDialogOverlay";
|
||||
var STYLE_ID = "wesp-shell-dialog-styles";
|
||||
|
||||
function injectStyles() {
|
||||
if (document.getElementById(STYLE_ID)) return;
|
||||
var s = document.createElement("style");
|
||||
s.id = STYLE_ID;
|
||||
s.textContent =
|
||||
"#" +
|
||||
OVERLAY_ID +
|
||||
"{" +
|
||||
"position:fixed;inset:0;z-index:5000;" +
|
||||
"display:none;align-items:center;justify-content:center;" +
|
||||
"padding:16px;background:rgba(0,0,0,.55);" +
|
||||
"box-sizing:border-box;" +
|
||||
"opacity:0;transition:opacity .22s cubic-bezier(.22,1,.36,1);" +
|
||||
"backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);" +
|
||||
"}" +
|
||||
"#" +
|
||||
OVERLAY_ID +
|
||||
'[aria-hidden="false"]{display:flex;opacity:1;}' +
|
||||
"#" +
|
||||
OVERLAY_ID +
|
||||
".wesp-shell-dialog-overlay-closing{opacity:0;}" +
|
||||
".wesp-shell-dialog{" +
|
||||
"width:min(420px,100%);max-height:min(80vh,520px);overflow:auto;" +
|
||||
"background:#fff;color:#111827;border-radius:8px;" +
|
||||
"border:1px solid #e5e7eb;box-shadow:0 8px 32px rgba(0,0,0,.18);" +
|
||||
"padding:20px 22px;font-family:var(--zootech-font,Inter,system-ui,sans-serif);" +
|
||||
"-webkit-font-smoothing:antialiased;" +
|
||||
"text-align:left;" +
|
||||
"opacity:0;transform:scale(.97) translateY(8px);" +
|
||||
"transition:opacity .22s cubic-bezier(.22,1,.36,1),transform .22s cubic-bezier(.22,1,.36,1);" +
|
||||
"}" +
|
||||
"#" +
|
||||
OVERLAY_ID +
|
||||
'[aria-hidden="false"]:not(.wesp-shell-dialog-overlay-closing) .wesp-shell-dialog{' +
|
||||
"opacity:1;transform:scale(1) translateY(0);" +
|
||||
"}" +
|
||||
"#" +
|
||||
OVERLAY_ID +
|
||||
".wesp-shell-dialog-overlay-closing .wesp-shell-dialog{" +
|
||||
"opacity:0;transform:scale(.97) translateY(8px);" +
|
||||
"}" +
|
||||
"@media (prefers-reduced-motion:reduce){" +
|
||||
"#" +
|
||||
OVERLAY_ID +
|
||||
",.wesp-shell-dialog{transition:none!important;transform:none!important;}" +
|
||||
"#" +
|
||||
OVERLAY_ID +
|
||||
"{backdrop-filter:none;-webkit-backdrop-filter:none;}" +
|
||||
".wesp-shell-dialog{opacity:1!important;}" +
|
||||
"}" +
|
||||
".wesp-shell-dialog-title{" +
|
||||
"margin:0 0 12px 0;font-size:1.125rem;font-weight:600;color:#111827;" +
|
||||
"}" +
|
||||
".wesp-shell-dialog-body{" +
|
||||
"margin:0;font-size:0.95rem;line-height:1.5;color:#374151;white-space:pre-wrap;word-break:break-word;" +
|
||||
"}" +
|
||||
".wesp-shell-dialog-actions{" +
|
||||
"display:flex;justify-content:flex-end;flex-wrap:wrap;gap:10px;margin-top:20px;" +
|
||||
"}" +
|
||||
".wesp-shell-dialog-btn{" +
|
||||
"min-width:120px;padding:10px 18px;border-radius:6px;font-size:0.95rem;font-weight:500;" +
|
||||
"cursor:pointer;font-family:inherit;border:1px solid #e5e7eb;background:#fff;color:#374151;" +
|
||||
"transition:background .2s,border-color .2s,box-shadow .2s;" +
|
||||
"}" +
|
||||
".wesp-shell-dialog-btn:hover{filter:brightness(.97);}" +
|
||||
".wesp-shell-dialog-btn-primary{" +
|
||||
"background:#2563eb;border-color:#2563eb;color:#fff;" +
|
||||
"}" +
|
||||
".wesp-shell-dialog-btn-primary:hover{box-shadow:0 4px 12px rgba(37,99,235,.25);}" +
|
||||
".wesp-shell-dialog-btn-danger{" +
|
||||
"background:#ef4444;border-color:#ef4444;color:#fff;" +
|
||||
"}" +
|
||||
".wesp-shell-dialog--danger{border-top:4px solid #ef4444;}" +
|
||||
".wesp-shell-dialog--success{border-top:4px solid #10b981;}" +
|
||||
"html[data-theme=dark] #" +
|
||||
OVERLAY_ID +
|
||||
" .wesp-shell-dialog{" +
|
||||
"background:var(--dm-surface,#2c3338);color:var(--dm-text,#f5f5f5);border-color:var(--dm-border,rgba(255,255,255,.12));" +
|
||||
"}" +
|
||||
"html[data-theme=dark] #" +
|
||||
OVERLAY_ID +
|
||||
" .wesp-shell-dialog-title{color:var(--dm-text,#f5f5f5);}" +
|
||||
"html[data-theme=dark] #" +
|
||||
OVERLAY_ID +
|
||||
" .wesp-shell-dialog-body{color:var(--dm-text,#f5f5f5);}" +
|
||||
"html[data-theme=dark] #" +
|
||||
OVERLAY_ID +
|
||||
" .wesp-shell-dialog-btn{" +
|
||||
"background:var(--dm-field,#3e444a);border-color:var(--dm-border,rgba(255,255,255,.12));color:var(--dm-text,#f5f5f5);" +
|
||||
"}" +
|
||||
"html[data-theme=dark] #" +
|
||||
OVERLAY_ID +
|
||||
" .wesp-shell-dialog-btn-primary{background:#2563eb;border-color:#2563eb;color:#fff;}" +
|
||||
"html[data-theme=dark] #" +
|
||||
OVERLAY_ID +
|
||||
" .wesp-shell-dialog-btn-danger{background:#dc2626;border-color:#dc2626;color:#fff;}" +
|
||||
"";
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
function ensureDom() {
|
||||
injectStyles();
|
||||
var el = document.getElementById(OVERLAY_ID);
|
||||
if (el) return el;
|
||||
el = document.createElement("div");
|
||||
el.id = OVERLAY_ID;
|
||||
el.setAttribute("aria-hidden", "true");
|
||||
el.innerHTML =
|
||||
'<div class="wesp-shell-dialog" role="dialog" aria-modal="true" aria-labelledby="wespShellDialogTitle">' +
|
||||
'<h2 class="wesp-shell-dialog-title" id="wespShellDialogTitle"></h2>' +
|
||||
'<p class="wesp-shell-dialog-body" id="wespShellDialogBody"></p>' +
|
||||
'<div class="wesp-shell-dialog-actions" id="wespShellDialogActions"></div>' +
|
||||
"</div>";
|
||||
el.addEventListener("click", function (e) {
|
||||
if (e.target === el) {
|
||||
backdropDismiss();
|
||||
}
|
||||
});
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
var pendingResolve = null;
|
||||
var pendingMode = null;
|
||||
var blockUserDismiss = false;
|
||||
|
||||
function backdropDismiss() {
|
||||
if (blockUserDismiss) return;
|
||||
if (pendingMode === "confirm" && pendingResolve) {
|
||||
var fn = pendingResolve;
|
||||
pendingResolve = null;
|
||||
pendingMode = null;
|
||||
hide();
|
||||
fn(false);
|
||||
} else if (pendingMode === "alert" && pendingResolve) {
|
||||
var fn2 = pendingResolve;
|
||||
pendingResolve = null;
|
||||
pendingMode = null;
|
||||
hide();
|
||||
fn2();
|
||||
}
|
||||
}
|
||||
|
||||
function prefersReducedMotion() {
|
||||
return (
|
||||
global.matchMedia &&
|
||||
global.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
);
|
||||
}
|
||||
|
||||
function hide() {
|
||||
var el = document.getElementById(OVERLAY_ID);
|
||||
if (!el || el.getAttribute("aria-hidden") === "true") return;
|
||||
var dlg = el.querySelector(".wesp-shell-dialog");
|
||||
if (dlg) dlg.removeAttribute("tabindex");
|
||||
blockUserDismiss = false;
|
||||
document.removeEventListener("keydown", onKey);
|
||||
|
||||
if (prefersReducedMotion()) {
|
||||
el.classList.remove("wesp-shell-dialog-overlay-closing");
|
||||
el.setAttribute("aria-hidden", "true");
|
||||
return;
|
||||
}
|
||||
|
||||
el.classList.add("wesp-shell-dialog-overlay-closing");
|
||||
var done = false;
|
||||
var finish = function () {
|
||||
if (done) return;
|
||||
done = true;
|
||||
el.classList.remove("wesp-shell-dialog-overlay-closing");
|
||||
el.setAttribute("aria-hidden", "true");
|
||||
};
|
||||
el.addEventListener(
|
||||
"transitionend",
|
||||
function (e) {
|
||||
if (e.target === el) finish();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
global.setTimeout(finish, 280);
|
||||
}
|
||||
|
||||
function closeActive() {
|
||||
if (pendingMode === "confirm" && pendingResolve) {
|
||||
var fnC = pendingResolve;
|
||||
pendingResolve = null;
|
||||
pendingMode = null;
|
||||
hide();
|
||||
fnC(false);
|
||||
} else if (pendingMode === "alert" && pendingResolve) {
|
||||
var fnA = pendingResolve;
|
||||
pendingResolve = null;
|
||||
pendingMode = null;
|
||||
hide();
|
||||
fnA();
|
||||
}
|
||||
}
|
||||
|
||||
function onKey(e) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
if (blockUserDismiss) return;
|
||||
backdropDismiss();
|
||||
}
|
||||
}
|
||||
|
||||
function show() {
|
||||
var el = document.getElementById(OVERLAY_ID);
|
||||
if (!el) return;
|
||||
el.classList.remove("wesp-shell-dialog-overlay-closing");
|
||||
el.setAttribute("aria-hidden", "false");
|
||||
document.addEventListener("keydown", onKey);
|
||||
if (!prefersReducedMotion()) {
|
||||
var dlg = el.querySelector(".wesp-shell-dialog");
|
||||
if (dlg) {
|
||||
dlg.style.opacity = "0";
|
||||
dlg.style.transform = "scale(0.97) translateY(8px)";
|
||||
requestAnimationFrame(function () {
|
||||
requestAnimationFrame(function () {
|
||||
dlg.style.opacity = "";
|
||||
dlg.style.transform = "";
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setDialogVariant(dialogEl, variant) {
|
||||
dialogEl.classList.remove("wesp-shell-dialog--danger", "wesp-shell-dialog--success");
|
||||
if (variant === "danger") dialogEl.classList.add("wesp-shell-dialog--danger");
|
||||
else if (variant === "success") dialogEl.classList.add("wesp-shell-dialog--success");
|
||||
}
|
||||
|
||||
function displayDialogMessage(message, opts) {
|
||||
const variant = opts && opts.variant;
|
||||
const fallback = opts && opts.fallback;
|
||||
if (variant === "danger" && global.WespUserMessages?.userFacingMessage) {
|
||||
return global.WespUserMessages.userFacingMessage(message, fallback || "Не удалось выполнить операцию. Попробуйте ещё раз.");
|
||||
}
|
||||
return message == null ? "" : String(message);
|
||||
}
|
||||
|
||||
function alert(message, opts) {
|
||||
opts = opts || {};
|
||||
return new Promise(function (resolve) {
|
||||
var overlay = ensureDom();
|
||||
var dialog = overlay.querySelector(".wesp-shell-dialog");
|
||||
var titleEl = document.getElementById("wespShellDialogTitle");
|
||||
var bodyEl = document.getElementById("wespShellDialogBody");
|
||||
var actions = document.getElementById("wespShellDialogActions");
|
||||
titleEl.textContent = opts.title != null ? String(opts.title) : "Сообщение";
|
||||
bodyEl.textContent = displayDialogMessage(message, opts);
|
||||
setDialogVariant(dialog, opts.variant || "info");
|
||||
actions.innerHTML = "";
|
||||
blockUserDismiss = !!opts.timerOnly;
|
||||
pendingMode = "alert";
|
||||
pendingResolve = resolve;
|
||||
if (!opts.timerOnly) {
|
||||
var ok = document.createElement("button");
|
||||
ok.type = "button";
|
||||
ok.className = "wesp-shell-dialog-btn wesp-shell-dialog-btn-primary";
|
||||
ok.textContent = opts.okText || "Понятно";
|
||||
ok.addEventListener("click", function () {
|
||||
pendingResolve = null;
|
||||
pendingMode = null;
|
||||
hide();
|
||||
resolve();
|
||||
});
|
||||
actions.appendChild(ok);
|
||||
show();
|
||||
ok.focus();
|
||||
} else {
|
||||
dialog.setAttribute("tabindex", "-1");
|
||||
show();
|
||||
dialog.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function confirm(message, opts) {
|
||||
opts = opts || {};
|
||||
return new Promise(function (resolve) {
|
||||
blockUserDismiss = false;
|
||||
var overlay = ensureDom();
|
||||
var dialog = overlay.querySelector(".wesp-shell-dialog");
|
||||
var titleEl = document.getElementById("wespShellDialogTitle");
|
||||
var bodyEl = document.getElementById("wespShellDialogBody");
|
||||
var actions = document.getElementById("wespShellDialogActions");
|
||||
titleEl.textContent = opts.title != null ? String(opts.title) : "Подтверждение";
|
||||
bodyEl.textContent = message == null ? "" : String(message);
|
||||
setDialogVariant(dialog, opts.variant || "info");
|
||||
actions.innerHTML = "";
|
||||
var cancel = document.createElement("button");
|
||||
cancel.type = "button";
|
||||
cancel.className = "wesp-shell-dialog-btn";
|
||||
cancel.textContent = opts.cancelText || "Отмена";
|
||||
var ok = document.createElement("button");
|
||||
ok.type = "button";
|
||||
ok.className =
|
||||
"wesp-shell-dialog-btn " +
|
||||
(opts.danger ? "wesp-shell-dialog-btn-danger" : "wesp-shell-dialog-btn-primary");
|
||||
ok.textContent = opts.okText || "Да";
|
||||
cancel.addEventListener("click", function () {
|
||||
pendingResolve = null;
|
||||
pendingMode = null;
|
||||
hide();
|
||||
resolve(false);
|
||||
});
|
||||
ok.addEventListener("click", function () {
|
||||
pendingResolve = null;
|
||||
pendingMode = null;
|
||||
hide();
|
||||
resolve(true);
|
||||
});
|
||||
actions.appendChild(cancel);
|
||||
actions.appendChild(ok);
|
||||
pendingMode = "confirm";
|
||||
pendingResolve = resolve;
|
||||
show();
|
||||
ok.focus();
|
||||
});
|
||||
}
|
||||
|
||||
global.WespDialog = {
|
||||
alert: alert,
|
||||
confirm: confirm,
|
||||
closeActive: closeActive,
|
||||
};
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Видимость Lab-элементов на zootech-страницах (components и др.).
|
||||
*/
|
||||
(function (global) {
|
||||
const HIDDEN_CLASS = "wesp-lab-access-hidden";
|
||||
|
||||
function ensureStyle() {
|
||||
if (document.getElementById("wespLabAccessStyle")) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = "wespLabAccessStyle";
|
||||
style.textContent = `.${HIDDEN_CLASS}{display:none !important;}`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function setHidden(el, hidden) {
|
||||
if (!el) return;
|
||||
el.classList.toggle(HIDDEN_CLASS, hidden);
|
||||
if (hidden) {
|
||||
el.setAttribute("aria-hidden", "true");
|
||||
} else {
|
||||
el.removeAttribute("aria-hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function apply(canLab) {
|
||||
ensureStyle();
|
||||
global.WESP_CAN_LAB = Boolean(canLab);
|
||||
document.documentElement.classList.toggle("wesp-no-lab", !canLab);
|
||||
|
||||
document.querySelectorAll("[data-wesp-lab-gated]").forEach((el) => {
|
||||
setHidden(el, !canLab);
|
||||
});
|
||||
|
||||
if (canLab) {
|
||||
global.WespLabComponentsNutrients?.init?.();
|
||||
}
|
||||
}
|
||||
|
||||
global.WespLabAccess = { apply };
|
||||
})(window);
|
||||
@@ -0,0 +1,869 @@
|
||||
const DEFAULT_BLUE_SRC = "/static/logo2.png";
|
||||
const LOGO_SRC_WIDTH = 2222;
|
||||
const LOGO_SRC_HEIGHT = 1024;
|
||||
/** ~28 строк сетки, как у старого logo2 (305px @ cell 11). */
|
||||
const INITIAL_CELL = 37;
|
||||
const DURATION_MS = 4750;
|
||||
const WAVE2_START_MS = 0.36 * 4500;
|
||||
const WAVE2_SPAN_MS = 0.34 * 6800;
|
||||
const WAVE2_START = WAVE2_START_MS / DURATION_MS;
|
||||
const WAVE2_SPAN = WAVE2_SPAN_MS / DURATION_MS;
|
||||
const WAVE2_END = (WAVE2_START_MS + WAVE2_SPAN_MS) / DURATION_MS;
|
||||
const HOLD_END = 0.94;
|
||||
const PIXEL_SCALE = WAVE2_START / 0.36;
|
||||
const WAVE0_START = 0.04 * PIXEL_SCALE;
|
||||
const WAVE0_SPAN = 0.20 * PIXEL_SCALE;
|
||||
const WAVE1_START = WAVE0_START + WAVE0_SPAN - 0.04 * PIXEL_SCALE;
|
||||
const WAVE1_SPAN = 0.20 * PIXEL_SCALE;
|
||||
const PIXEL_FADE = 0.012;
|
||||
const PIXEL_GAP = 1;
|
||||
const ASSEMBLY_FEATHER = 10;
|
||||
const COLOR_CROSSFADE = 0.024;
|
||||
const SHIMMER_MS = 1400;
|
||||
const SHIMMER_PROGRESS_START = 88;
|
||||
const BRAND_BLUE = [44, 123, 229];
|
||||
const BRAND_ORGANIC = [72, 129, 109];
|
||||
const BRAND_ORGANIC_HC = [47, 107, 87];
|
||||
const BRAND_WHITE = [238, 240, 243];
|
||||
const PIXEL_GRAY = [148, 152, 158];
|
||||
const BG = [246, 246, 244];
|
||||
const GRID_STEP = 10;
|
||||
|
||||
function shimmerDurationMs() {
|
||||
return SHIMMER_MS;
|
||||
}
|
||||
|
||||
function wave2EndMs(durationMs) {
|
||||
return WAVE2_END * durationMs;
|
||||
}
|
||||
|
||||
function animationEndMs(durationMs) {
|
||||
return Math.max(durationMs, wave2EndMs(durationMs) + SHIMMER_MS + 180);
|
||||
}
|
||||
|
||||
/** Фактическая длительность проигрывания (с учётом totalDurationMs). */
|
||||
function resolvePlaybackEndMs(durationMs, totalDurationMs) {
|
||||
const natural = animationEndMs(durationMs);
|
||||
if (totalDurationMs == null || !Number.isFinite(totalDurationMs)) {
|
||||
return natural;
|
||||
}
|
||||
return Math.min(natural, Math.max(400, totalDurationMs));
|
||||
}
|
||||
|
||||
export const LOGO_LOADER_DURATION_MS = DURATION_MS;
|
||||
export { animationEndMs, resolvePlaybackEndMs, drawShimmer, LOGO_SRC_WIDTH, LOGO_SRC_HEIGHT };
|
||||
|
||||
/** Буфер логотипа для navbar-shimmer (выравнивание слева, как object-position: left). */
|
||||
export function createNavLogoShimmerBuffer(img, slotW, slotH, inkRgb = [255, 255, 255]) {
|
||||
const srcW = img.naturalWidth || LOGO_SRC_WIDTH;
|
||||
const srcH = img.naturalHeight || LOGO_SRC_HEIGHT;
|
||||
const scale = Math.min(slotW / srcW, slotH / srcH);
|
||||
const drawWidth = srcW * scale;
|
||||
const drawHeight = srcH * scale;
|
||||
const layout = {
|
||||
offsetX: 0,
|
||||
offsetY: (slotH - drawHeight) / 2,
|
||||
drawWidth,
|
||||
drawHeight,
|
||||
};
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = slotW;
|
||||
canvas.height = slotH;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.clearRect(0, 0, slotW, slotH);
|
||||
ctx.drawImage(img, layout.offsetX, layout.offsetY, drawWidth, drawHeight);
|
||||
knockoutBackgroundFromLogo(canvas, slotW, slotH);
|
||||
if (inkRgb) recolorVisiblePixels(canvas, slotW, slotH, inkRgb);
|
||||
return { logoCanvas: canvas, layout };
|
||||
}
|
||||
|
||||
function prefersReducedMotion() {
|
||||
return globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false;
|
||||
}
|
||||
|
||||
/** light | organic | dark — для цвета логотипа в анимации загрузки */
|
||||
function resolveLoaderTheme(options = {}) {
|
||||
const explicit = options.theme;
|
||||
if (explicit === "light" || explicit === "organic" || explicit === "dark") {
|
||||
return explicit;
|
||||
}
|
||||
const docTheme = document.documentElement.getAttribute("data-theme");
|
||||
if (docTheme === "light" || docTheme === "organic" || docTheme === "dark") {
|
||||
return docTheme;
|
||||
}
|
||||
try {
|
||||
const saved = localStorage.getItem("theme");
|
||||
if (saved === "light" || saved === "organic" || saved === "dark") return saved;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (options.darkTheme) return "dark";
|
||||
return "light";
|
||||
}
|
||||
|
||||
function prefersHighContrast() {
|
||||
const attr = document.documentElement.getAttribute("data-high-contrast");
|
||||
if (attr === "true") return true;
|
||||
if (attr === "false") return false;
|
||||
return globalThis.matchMedia?.("(prefers-contrast: more)")?.matches ?? false;
|
||||
}
|
||||
|
||||
function loaderAccentRgb(theme) {
|
||||
if (theme === "dark") return BRAND_WHITE;
|
||||
if (theme === "organic") return prefersHighContrast() ? BRAND_ORGANIC_HC : BRAND_ORGANIC;
|
||||
return BRAND_BLUE;
|
||||
}
|
||||
|
||||
function loaderUsesDarkShimmer(theme) {
|
||||
return theme === "dark";
|
||||
}
|
||||
|
||||
function loadImage(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error(`Failed to load image: ${src}`));
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
function isLogoInk(data, index) {
|
||||
const r = data[index];
|
||||
const g = data[index + 1];
|
||||
const b = data[index + 2];
|
||||
const a = data[index + 3];
|
||||
if (a < 20) return false;
|
||||
if (r + g + b < 80) return false;
|
||||
return b > 70 && b >= r && b >= g * 0.85;
|
||||
}
|
||||
|
||||
function isLogoBlueFringe(data, index) {
|
||||
const r = data[index];
|
||||
const g = data[index + 1];
|
||||
const b = data[index + 2];
|
||||
const a = data[index + 3];
|
||||
if (a < 1) return false;
|
||||
if (isLogoInk(data, index)) return true;
|
||||
return b > r + 5 && b > g - 2 && b > 68 && r + g + b < 700;
|
||||
}
|
||||
|
||||
function knockoutBackgroundFromLogo(canvas, width, height) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
const data = imageData.data;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
if (data[i + 3] < 1) continue;
|
||||
if (isLogoBlueFringe(data, i)) continue;
|
||||
data[i + 3] = 0;
|
||||
}
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
function recolorVisiblePixels(canvas, width, height, rgb) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
const data = imageData.data;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
if (data[i + 3] < 1) continue;
|
||||
data[i] = rgb[0];
|
||||
data[i + 1] = rgb[1];
|
||||
data[i + 2] = rgb[2];
|
||||
}
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
function cloneLogoBuffer(sourceCanvas, width, height, rgb = null) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.drawImage(sourceCanvas, 0, 0);
|
||||
if (rgb) recolorVisiblePixels(canvas, width, height, rgb);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function drawLogoToBuffer(img, width, height, knockoutBackground = false) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
const scale = Math.min(width / img.naturalWidth, height / img.naturalHeight);
|
||||
const drawWidth = img.naturalWidth * scale;
|
||||
const drawHeight = img.naturalHeight * scale;
|
||||
const offsetX = (width - drawWidth) / 2;
|
||||
const offsetY = (height - drawHeight) / 2;
|
||||
ctx.drawImage(img, offsetX, offsetY, drawWidth, drawHeight);
|
||||
if (knockoutBackground) knockoutBackgroundFromLogo(canvas, width, height);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function hashCell(col, row) {
|
||||
return ((col * 92837111) ^ (row * 689287499)) >>> 0;
|
||||
}
|
||||
|
||||
function easeOutQuad(t) {
|
||||
return 1 - (1 - t) * (1 - t);
|
||||
}
|
||||
|
||||
function easeInOutQuad(t) {
|
||||
return t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
|
||||
}
|
||||
|
||||
function rgbaString(r, g, b, a) {
|
||||
return `rgba(${r | 0}, ${g | 0}, ${b | 0}, ${a})`;
|
||||
}
|
||||
|
||||
function desaturateColor(r, g, b, amount = 0.72) {
|
||||
const gray = (r + g + b) / 3;
|
||||
return [
|
||||
r + (gray - r) * amount,
|
||||
g + (gray - g) * amount,
|
||||
b + (gray - b) * amount,
|
||||
];
|
||||
}
|
||||
|
||||
function lerpColor(c1, c2, t) {
|
||||
return [
|
||||
c1[0] + (c2[0] - c1[0]) * t,
|
||||
c1[1] + (c2[1] - c1[1]) * t,
|
||||
c1[2] + (c2[2] - c1[2]) * t,
|
||||
];
|
||||
}
|
||||
|
||||
function colorCss(color) {
|
||||
return `rgb(${color[0] | 0}, ${color[1] | 0}, ${color[2] | 0})`;
|
||||
}
|
||||
|
||||
function displayProgress(t, elapsedMs = null, durationMs = DURATION_MS) {
|
||||
const shimmerStartMs = wave2EndMs(durationMs);
|
||||
const shimmerEndMs = shimmerStartMs + shimmerDurationMs();
|
||||
|
||||
if (elapsedMs != null && elapsedMs >= shimmerStartMs) {
|
||||
if (elapsedMs >= shimmerEndMs) return 100;
|
||||
const shimmerP = (elapsedMs - shimmerStartMs) / shimmerDurationMs();
|
||||
return Math.round(
|
||||
SHIMMER_PROGRESS_START + shimmerP * (100 - SHIMMER_PROGRESS_START),
|
||||
);
|
||||
}
|
||||
|
||||
if (t >= WAVE2_END) return SHIMMER_PROGRESS_START;
|
||||
if (t >= WAVE2_START) {
|
||||
const p = (t - WAVE2_START) / (WAVE2_END - WAVE2_START);
|
||||
return Math.round(
|
||||
65 + Math.min(1, p) * (SHIMMER_PROGRESS_START - 65),
|
||||
);
|
||||
}
|
||||
if (t >= WAVE1_START) {
|
||||
const wave1End = WAVE1_START + WAVE1_SPAN;
|
||||
const p = Math.min(1, (t - WAVE1_START) / Math.max(0.001, wave1End - WAVE1_START));
|
||||
return Math.round(35 + p * 30);
|
||||
}
|
||||
if (t >= WAVE0_START) {
|
||||
const wave0End = WAVE0_START + WAVE0_SPAN;
|
||||
const p = Math.min(1, (t - WAVE0_START) / Math.max(0.001, wave0End - WAVE0_START));
|
||||
return Math.round(p * 35);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function progressBarColor(progress, loaderTheme = "light") {
|
||||
const accent = loaderAccentRgb(loaderTheme);
|
||||
if (progress >= 65) return colorCss(accent);
|
||||
if (progress >= 35) {
|
||||
const p = (progress - 35) / 30;
|
||||
return colorCss(lerpColor(PIXEL_GRAY, accent, p));
|
||||
}
|
||||
return colorCss(PIXEL_GRAY);
|
||||
}
|
||||
|
||||
function withLogoTransform(ctx, layout, t, drawFn) {
|
||||
drawFn();
|
||||
}
|
||||
|
||||
/** Фаза 0 — серые пиксели; фаза 1 — синие/белые; обе слева направо. */
|
||||
function buildLogoCells(logoData, logoWidth, logoHeight, cell, accentColor = null) {
|
||||
const cols = Math.ceil(logoWidth / cell);
|
||||
const rows = Math.ceil(logoHeight / cell);
|
||||
const colDenom = Math.max(1, cols - 1);
|
||||
const cells = [];
|
||||
|
||||
for (let row = 0; row < rows; row += 1) {
|
||||
for (let col = 0; col < cols; col += 1) {
|
||||
const cx = Math.min(logoWidth - 1, col * cell + Math.floor(cell / 2));
|
||||
const cy = Math.min(logoHeight - 1, row * cell + Math.floor(cell / 2));
|
||||
const index = (cy * logoWidth + cx) * 4;
|
||||
if (!isLogoInk(logoData, index)) continue;
|
||||
|
||||
const r = logoData[index];
|
||||
const g = logoData[index + 1];
|
||||
const b = logoData[index + 2];
|
||||
const noise = (hashCell(col, row) % 1000) / 1000;
|
||||
const sweep = Math.max(0, Math.min(1, col / colDenom + (noise - 0.5) * 0.028));
|
||||
const color = accentColor ?? [r, g, b];
|
||||
|
||||
cells.push({
|
||||
col,
|
||||
row,
|
||||
wave0At: WAVE0_START + sweep * WAVE0_SPAN,
|
||||
wave1At: WAVE1_START + sweep * WAVE1_SPAN,
|
||||
color,
|
||||
gray: desaturateColor(r, g, b),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return cells;
|
||||
}
|
||||
|
||||
function wave2FrontX(t, layout) {
|
||||
if (t < WAVE2_START) {
|
||||
return layout.offsetX;
|
||||
}
|
||||
const progress = easeInOutQuad(Math.min(1, (t - WAVE2_START) / WAVE2_SPAN));
|
||||
return layout.offsetX + layout.drawWidth * progress;
|
||||
}
|
||||
|
||||
function drawBackground(ctx, width, height, layout, t, plain = false, transparent = false) {
|
||||
if (transparent) {
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
return;
|
||||
}
|
||||
|
||||
if (plain) {
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
return;
|
||||
}
|
||||
|
||||
const cx = width / 2;
|
||||
const cy = layout ? layout.offsetY + layout.drawHeight / 2 : height * 0.44;
|
||||
const bgGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.max(width, height) * 0.72);
|
||||
bgGrad.addColorStop(0, "#FAFAF8");
|
||||
bgGrad.addColorStop(1, "#F0F0EE");
|
||||
ctx.fillStyle = bgGrad;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
const animating = t > 0 && t < HOLD_END;
|
||||
const logoCx = layout ? layout.offsetX + layout.drawWidth / 2 : cx;
|
||||
const logoCy = layout ? layout.offsetY + layout.drawHeight / 2 : cy;
|
||||
const focusRadius = layout
|
||||
? Math.max(layout.drawWidth, layout.drawHeight) * 0.72
|
||||
: Math.min(width, height) * 0.35;
|
||||
|
||||
for (let x = GRID_STEP / 2; x < width; x += GRID_STEP) {
|
||||
for (let y = GRID_STEP / 2; y < height; y += GRID_STEP) {
|
||||
let alpha = 0.055;
|
||||
if (layout && animating) {
|
||||
const dist = Math.hypot(x - logoCx, y - logoCy);
|
||||
if (dist < focusRadius) {
|
||||
alpha += 0.045 * (1 - dist / focusRadius);
|
||||
}
|
||||
}
|
||||
ctx.fillStyle = `rgba(26, 30, 28, ${alpha})`;
|
||||
ctx.fillRect(x, y, 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
const vig = ctx.createRadialGradient(cx, cy, Math.min(width, height) * 0.28, cx, cy, Math.max(width, height) * 0.82);
|
||||
vig.addColorStop(0, "rgba(26, 30, 28, 0)");
|
||||
vig.addColorStop(1, "rgba(26, 30, 28, 0.07)");
|
||||
ctx.fillStyle = vig;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
}
|
||||
|
||||
function ensureLoaderChrome(root, hideChrome = false) {
|
||||
let canvas = root.querySelector(".wesp-logo-loader__canvas");
|
||||
let footer = root.querySelector(".wesp-logo-loader__footer");
|
||||
|
||||
if (!canvas) {
|
||||
canvas = document.createElement("canvas");
|
||||
canvas.className = "wesp-logo-loader__canvas";
|
||||
root.appendChild(canvas);
|
||||
}
|
||||
|
||||
if (!footer && !hideChrome) {
|
||||
footer = document.createElement("div");
|
||||
footer.className = "wesp-logo-loader__footer";
|
||||
footer.innerHTML = `
|
||||
<div class="wesp-logo-loader__footer-row">
|
||||
<span class="wesp-logo-loader__label">Загрузка</span>
|
||||
<span class="wesp-logo-loader__percent">00%</span>
|
||||
</div>
|
||||
<div class="wesp-logo-loader__bar">
|
||||
<div class="wesp-logo-loader__bar-fill"></div>
|
||||
</div>
|
||||
<p class="wesp-logo-loader__tagline" aria-live="polite"></p>`;
|
||||
root.appendChild(footer);
|
||||
}
|
||||
|
||||
return {
|
||||
canvas,
|
||||
percentEl: footer?.querySelector(".wesp-logo-loader__percent") ?? null,
|
||||
barFill: footer?.querySelector(".wesp-logo-loader__bar-fill") ?? null,
|
||||
taglineEl: footer?.querySelector(".wesp-logo-loader__tagline") ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function computeLogoLayout(viewport, logoWidth, logoHeight, embedded = false) {
|
||||
const maxWidth = viewport.w * (embedded ? 0.94 : 0.88);
|
||||
const maxHeight = viewport.h * (embedded ? 0.88 : 0.34);
|
||||
const scale = Math.min(maxWidth / logoWidth, maxHeight / logoHeight);
|
||||
const drawWidth = logoWidth * scale;
|
||||
const drawHeight = logoHeight * scale;
|
||||
const offsetX = (viewport.w - drawWidth) / 2;
|
||||
const offsetY = embedded
|
||||
? (viewport.h - drawHeight) / 2
|
||||
: viewport.h * 0.44 - drawHeight / 2;
|
||||
|
||||
return { scale, drawWidth, drawHeight, offsetX, offsetY };
|
||||
}
|
||||
|
||||
function drawSharpLogo(ctx, offBlue, layout, alpha = 1) {
|
||||
if (alpha <= 0) return;
|
||||
ctx.save();
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.drawImage(offBlue, layout.offsetX, layout.offsetY, layout.drawWidth, layout.drawHeight);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function pixelGrid(layout, cell, logoWidth, logoHeight) {
|
||||
const cols = Math.ceil(logoWidth / cell);
|
||||
const rows = Math.ceil(logoHeight / cell);
|
||||
return {
|
||||
originX: layout.offsetX,
|
||||
originY: layout.offsetY,
|
||||
cellW: layout.drawWidth / cols,
|
||||
cellH: layout.drawHeight / rows,
|
||||
};
|
||||
}
|
||||
|
||||
function cellRect(grid, col, row) {
|
||||
const x0 = grid.originX + col * grid.cellW;
|
||||
const y0 = grid.originY + row * grid.cellH;
|
||||
const x1 = grid.originX + (col + 1) * grid.cellW;
|
||||
const y1 = grid.originY + (row + 1) * grid.cellH;
|
||||
return {
|
||||
x: Math.round(x0),
|
||||
y: Math.round(y0),
|
||||
w: Math.max(1, Math.round(x1) - Math.round(x0)),
|
||||
h: Math.max(1, Math.round(y1) - Math.round(y0)),
|
||||
};
|
||||
}
|
||||
|
||||
function drawPixelBlock(ctx, rect, color, alpha) {
|
||||
if (alpha <= 0) return;
|
||||
const gap = Math.min(PIXEL_GAP, Math.max(0, rect.w - 1), Math.max(0, rect.h - 1));
|
||||
const inset = gap * 0.5;
|
||||
const x = rect.x + inset;
|
||||
const y = rect.y + inset;
|
||||
const w = Math.max(1, rect.w - gap);
|
||||
const h = Math.max(1, rect.h - gap);
|
||||
ctx.fillStyle = rgbaString(color[0], color[1], color[2], alpha);
|
||||
ctx.fillRect(x, y, w, h);
|
||||
}
|
||||
|
||||
function pixelAppear(t, startAt) {
|
||||
const appear = Math.min(1, (t - startAt) / PIXEL_FADE);
|
||||
return appear >= 0.7 ? 1 : appear;
|
||||
}
|
||||
|
||||
function resolvePixelColor(item, t) {
|
||||
if (t < item.wave1At) return item.gray;
|
||||
const blend = Math.min(1, (t - item.wave1At) / COLOR_CROSSFADE);
|
||||
if (blend >= 1) return item.color;
|
||||
return lerpColor(item.gray, item.color, blend);
|
||||
}
|
||||
|
||||
function resolvePixelAlpha(item, t) {
|
||||
if (t < item.wave0At) return 0;
|
||||
let alpha = pixelAppear(t, item.wave0At);
|
||||
if (t >= item.wave1At) {
|
||||
alpha = Math.max(alpha, pixelAppear(t, item.wave1At));
|
||||
}
|
||||
return alpha;
|
||||
}
|
||||
|
||||
function featherPixelAlpha(alpha, cellMid, frontX, feather) {
|
||||
if (cellMid <= frontX || cellMid >= frontX + feather) return alpha;
|
||||
return alpha * ((cellMid - frontX) / feather);
|
||||
}
|
||||
|
||||
function drawSharpLogoFeathered(ctx, logoCanvas, layout, frontX, feather) {
|
||||
const left = layout.offsetX;
|
||||
const top = layout.offsetY;
|
||||
const width = layout.drawWidth;
|
||||
const height = layout.drawHeight;
|
||||
const softStart = Math.max(left, frontX - feather);
|
||||
|
||||
if (softStart > left + 0.5) {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(left, top, softStart - left, height);
|
||||
ctx.clip();
|
||||
drawSharpLogo(ctx, logoCanvas, layout, 1);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
const bandLeft = softStart;
|
||||
const bandRight = Math.min(left + width, frontX);
|
||||
if (bandRight <= bandLeft + 0.5) return;
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(bandLeft, top, bandRight - bandLeft, height);
|
||||
ctx.clip();
|
||||
drawSharpLogo(ctx, logoCanvas, layout, 1);
|
||||
ctx.globalCompositeOperation = "destination-in";
|
||||
const grad = ctx.createLinearGradient(bandLeft, 0, bandRight, 0);
|
||||
grad.addColorStop(0, "rgba(255,255,255,1)");
|
||||
grad.addColorStop(1, "rgba(255,255,255,0)");
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(bandLeft, top, bandRight - bandLeft, height);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawShimmer(ctx, layout, logoCanvas, shimmerT, loaderTheme = "light") {
|
||||
const darkShimmer = loaderUsesDarkShimmer(loaderTheme);
|
||||
const bandW = layout.drawWidth * (darkShimmer ? 0.36 : 0.14);
|
||||
const x = layout.offsetX + (layout.drawWidth + bandW) * shimmerT - bandW;
|
||||
const left = layout.offsetX;
|
||||
const top = layout.offsetY;
|
||||
const width = layout.drawWidth;
|
||||
const height = layout.drawHeight;
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(left, top, width, height);
|
||||
ctx.clip();
|
||||
|
||||
if (darkShimmer) {
|
||||
const [gr, gg, gb] = PIXEL_GRAY;
|
||||
const grayWave = ctx.createLinearGradient(x, 0, x + bandW, 0);
|
||||
grayWave.addColorStop(0, "rgba(255,255,255,0)");
|
||||
grayWave.addColorStop(0.22, "rgba(255,255,255,0)");
|
||||
grayWave.addColorStop(0.5, `rgba(${gr},${gg},${gb},0.95)`);
|
||||
grayWave.addColorStop(0.78, "rgba(255,255,255,0)");
|
||||
grayWave.addColorStop(1, "rgba(255,255,255,0)");
|
||||
ctx.globalCompositeOperation = "source-atop";
|
||||
ctx.fillStyle = grayWave;
|
||||
ctx.fillRect(left, top, width, height);
|
||||
ctx.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
const peak = 0.72;
|
||||
const highlight = ctx.createLinearGradient(x, 0, x + bandW, 0);
|
||||
highlight.addColorStop(0, "rgba(255,255,255,0)");
|
||||
highlight.addColorStop(0.3, "rgba(255,255,255,0)");
|
||||
highlight.addColorStop(0.5, `rgba(255,255,255,${peak})`);
|
||||
highlight.addColorStop(0.7, "rgba(255,255,255,0)");
|
||||
highlight.addColorStop(1, "rgba(255,255,255,0)");
|
||||
ctx.globalCompositeOperation = "lighter";
|
||||
ctx.fillStyle = highlight;
|
||||
ctx.fillRect(left, top, width, height);
|
||||
ctx.globalCompositeOperation = "destination-in";
|
||||
ctx.drawImage(logoCanvas, left, top, width, height);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/** Фаза 0 — серые; фаза 1 — цветные; фаза 2 — плавная сборка по контуру надписи. */
|
||||
function drawWaves(ctx, layout, cell, cells, t, logoCanvas, logoWidth, logoHeight) {
|
||||
const frontX = wave2FrontX(t, layout);
|
||||
const sharpRight = layout.offsetX + layout.drawWidth;
|
||||
const grid = pixelGrid(layout, cell, logoWidth, logoHeight);
|
||||
const inWave2 = t >= WAVE2_START;
|
||||
|
||||
if (inWave2 && frontX > layout.offsetX + 1) {
|
||||
drawSharpLogoFeathered(ctx, logoCanvas, layout, frontX, ASSEMBLY_FEATHER);
|
||||
}
|
||||
|
||||
if (inWave2 && frontX >= sharpRight - 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of cells) {
|
||||
if (t < item.wave0At) continue;
|
||||
|
||||
const rect = cellRect(grid, item.col, item.row);
|
||||
const cellMid = rect.x + rect.w * 0.5;
|
||||
if (inWave2 && cellMid < frontX - ASSEMBLY_FEATHER) continue;
|
||||
|
||||
let alpha = resolvePixelAlpha(item, t);
|
||||
if (inWave2) {
|
||||
alpha = featherPixelAlpha(alpha, cellMid, frontX, ASSEMBLY_FEATHER);
|
||||
}
|
||||
if (alpha <= 0) continue;
|
||||
|
||||
drawPixelBlock(ctx, rect, resolvePixelColor(item, t), alpha);
|
||||
}
|
||||
}
|
||||
|
||||
function updateProgressChrome(
|
||||
percentEl,
|
||||
barFill,
|
||||
t,
|
||||
getExternalProgress,
|
||||
loaderTheme,
|
||||
elapsedMs = null,
|
||||
durationMs = DURATION_MS,
|
||||
) {
|
||||
let progress = displayProgress(t, elapsedMs, durationMs);
|
||||
const external = getExternalProgress();
|
||||
if (external != null) {
|
||||
progress = Math.max(progress, Math.min(100, Math.round(external * 100)));
|
||||
}
|
||||
|
||||
if (percentEl) percentEl.textContent = `${String(progress).padStart(2, "0")}%`;
|
||||
if (barFill) {
|
||||
barFill.style.width = `${progress}%`;
|
||||
barFill.style.background = progressBarColor(progress, loaderTheme);
|
||||
}
|
||||
|
||||
return progress;
|
||||
}
|
||||
|
||||
export async function mountWespLogoLoader(root, options = {}) {
|
||||
if (!root) {
|
||||
throw new Error("mountWespLogoLoader: root element is required");
|
||||
}
|
||||
|
||||
const blueSrc = options.blueSrc ?? options.whiteSrc ?? DEFAULT_BLUE_SRC;
|
||||
const initialCell = options.cell ?? options.pixel ?? INITIAL_CELL;
|
||||
const loop = options.loop !== false;
|
||||
const durationMs = options.durationMs ?? DURATION_MS;
|
||||
const totalDurationMs = options.totalDurationMs ?? null;
|
||||
const playbackEndMs = resolvePlaybackEndMs(durationMs, totalDurationMs);
|
||||
const embedded = !!options.embedded;
|
||||
const plainBackground = !!options.plainBackground;
|
||||
const transparentBackground = !!options.transparentBackground;
|
||||
const knockoutBackground = !!options.knockoutBackground;
|
||||
const loaderTheme = resolveLoaderTheme(options);
|
||||
const staticLogo = !!options.staticLogo;
|
||||
const hideChrome = !!options.hideChrome;
|
||||
const onComplete = typeof options.onComplete === "function" ? options.onComplete : null;
|
||||
const getExternalProgress = typeof options.progress === "function"
|
||||
? options.progress
|
||||
: () => (options.progress ?? null);
|
||||
|
||||
root.classList.add("wesp-logo-loader");
|
||||
if (embedded) root.classList.add("wesp-logo-loader--embedded");
|
||||
if (plainBackground) root.classList.add("wesp-logo-loader--plain");
|
||||
if (transparentBackground) root.classList.add("wesp-logo-loader--transparent");
|
||||
if (hideChrome) root.classList.add("wesp-logo-loader--no-chrome");
|
||||
|
||||
const { canvas, percentEl, barFill } = ensureLoaderChrome(root, hideChrome);
|
||||
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (transparentBackground) {
|
||||
canvas.style.background = "transparent";
|
||||
}
|
||||
|
||||
const blueImg = await loadImage(blueSrc);
|
||||
const logoWidth = blueImg.naturalWidth || LOGO_SRC_WIDTH;
|
||||
const logoHeight = blueImg.naturalHeight || LOGO_SRC_HEIGHT;
|
||||
const offBlue = drawLogoToBuffer(
|
||||
blueImg,
|
||||
logoWidth,
|
||||
logoHeight,
|
||||
knockoutBackground,
|
||||
);
|
||||
const accentRgb = loaderAccentRgb(loaderTheme);
|
||||
const displayLogo = loaderTheme === "light"
|
||||
? offBlue
|
||||
: cloneLogoBuffer(offBlue, logoWidth, logoHeight, accentRgb);
|
||||
const waveAccent = loaderTheme === "light" ? null : accentRgb;
|
||||
const logoData = offBlue.getContext("2d").getImageData(0, 0, logoWidth, logoHeight).data;
|
||||
const logoCells = buildLogoCells(
|
||||
logoData,
|
||||
logoWidth,
|
||||
logoHeight,
|
||||
initialCell,
|
||||
waveAccent,
|
||||
);
|
||||
|
||||
let viewport = { w: 0, h: 0, dpr: 1 };
|
||||
let layout = null;
|
||||
let rafId = 0;
|
||||
let start = performance.now();
|
||||
let disposed = false;
|
||||
let completedFired = false;
|
||||
|
||||
function finishIfDone(elapsedMs) {
|
||||
if (loop || elapsedMs < playbackEndMs || completedFired || !onComplete) return;
|
||||
completedFired = true;
|
||||
window.setTimeout(() => onComplete(), 120);
|
||||
}
|
||||
|
||||
function readViewportSize() {
|
||||
if (embedded) {
|
||||
const rect = root.getBoundingClientRect();
|
||||
return {
|
||||
w: Math.max(1, rect.width || root.clientWidth || 1),
|
||||
h: Math.max(1, rect.height || root.clientHeight || 1),
|
||||
};
|
||||
}
|
||||
return { w: globalThis.innerWidth, h: globalThis.innerHeight };
|
||||
}
|
||||
|
||||
function resize() {
|
||||
const dpr = Math.min(globalThis.devicePixelRatio || 1, 2);
|
||||
const size = readViewportSize();
|
||||
viewport = { w: size.w, h: size.h, dpr };
|
||||
|
||||
canvas.width = viewport.w * dpr;
|
||||
canvas.height = viewport.h * dpr;
|
||||
canvas.style.width = `${viewport.w}px`;
|
||||
canvas.style.height = `${viewport.h}px`;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
layout = computeLogoLayout(viewport, logoWidth, logoHeight, embedded);
|
||||
}
|
||||
|
||||
function drawStaticLogo() {
|
||||
resize();
|
||||
drawBackground(ctx, viewport.w, viewport.h, layout, 1, plainBackground, transparentBackground);
|
||||
if (layout) drawSharpLogo(ctx, displayLogo, layout, 1);
|
||||
updateProgressChrome(
|
||||
percentEl,
|
||||
barFill,
|
||||
1,
|
||||
getExternalProgress,
|
||||
loaderTheme,
|
||||
animationEndMs(durationMs),
|
||||
durationMs,
|
||||
);
|
||||
finishIfDone(playbackEndMs);
|
||||
}
|
||||
|
||||
function drawAnimatedFrame(t, elapsedMs) {
|
||||
const shimmerMs = shimmerDurationMs();
|
||||
const shimmerStartMs = wave2EndMs(durationMs);
|
||||
|
||||
withLogoTransform(ctx, layout, t, () => {
|
||||
if (t >= WAVE2_END) {
|
||||
drawSharpLogo(ctx, displayLogo, layout, 1);
|
||||
if (
|
||||
elapsedMs >= shimmerStartMs
|
||||
&& elapsedMs < shimmerStartMs + shimmerMs
|
||||
) {
|
||||
drawShimmer(
|
||||
ctx,
|
||||
layout,
|
||||
displayLogo,
|
||||
(elapsedMs - shimmerStartMs) / shimmerMs,
|
||||
loaderTheme,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
drawWaves(ctx, layout, initialCell, logoCells, t, displayLogo, logoWidth, logoHeight);
|
||||
});
|
||||
}
|
||||
|
||||
function drawStaticIntroFrame(t) {
|
||||
drawBackground(ctx, viewport.w, viewport.h, layout, t, plainBackground, transparentBackground);
|
||||
if (!layout) return;
|
||||
const alpha = easeOutQuad(Math.min(1, t / 0.82));
|
||||
drawSharpLogo(ctx, displayLogo, layout, alpha);
|
||||
}
|
||||
|
||||
function cycleElapsedMs(elapsed) {
|
||||
if (!loop) return elapsed;
|
||||
return elapsed % durationMs;
|
||||
}
|
||||
|
||||
function drawFrame(now) {
|
||||
if (disposed) return;
|
||||
|
||||
const elapsed = now - start;
|
||||
const cycleMs = cycleElapsedMs(elapsed);
|
||||
const t = loop
|
||||
? cycleMs / durationMs
|
||||
: Math.min(1, elapsed / durationMs);
|
||||
const shimmerMs = shimmerDurationMs();
|
||||
const shimmerStartMs = wave2EndMs(durationMs);
|
||||
const inShimmer =
|
||||
cycleMs >= shimmerStartMs && cycleMs < shimmerStartMs + shimmerMs;
|
||||
|
||||
updateProgressChrome(
|
||||
percentEl,
|
||||
barFill,
|
||||
t,
|
||||
getExternalProgress,
|
||||
loaderTheme,
|
||||
cycleMs,
|
||||
durationMs,
|
||||
);
|
||||
|
||||
if (staticLogo) {
|
||||
drawStaticIntroFrame(t);
|
||||
finishIfDone(elapsed);
|
||||
if (loop || elapsed < playbackEndMs) {
|
||||
rafId = requestAnimationFrame(drawFrame);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
drawBackground(ctx, viewport.w, viewport.h, layout, t, plainBackground, transparentBackground);
|
||||
if (!layout) return;
|
||||
|
||||
if (t < HOLD_END || inShimmer) {
|
||||
drawAnimatedFrame(t, cycleMs);
|
||||
} else if (loop) {
|
||||
withLogoTransform(ctx, layout, t, () => {
|
||||
const fade = 1 - easeOutQuad((t - HOLD_END) / (1 - HOLD_END));
|
||||
drawSharpLogo(ctx, displayLogo, layout, fade);
|
||||
});
|
||||
} else {
|
||||
withLogoTransform(ctx, layout, t, () => {
|
||||
drawSharpLogo(ctx, displayLogo, layout, 1);
|
||||
});
|
||||
}
|
||||
|
||||
finishIfDone(elapsed);
|
||||
|
||||
if (loop || elapsed < playbackEndMs) {
|
||||
rafId = requestAnimationFrame(drawFrame);
|
||||
}
|
||||
}
|
||||
|
||||
resize();
|
||||
globalThis.addEventListener("resize", resize);
|
||||
|
||||
if (prefersReducedMotion() || staticLogo) {
|
||||
if (staticLogo) {
|
||||
rafId = requestAnimationFrame(drawFrame);
|
||||
} else {
|
||||
drawStaticLogo();
|
||||
}
|
||||
} else {
|
||||
rafId = requestAnimationFrame(drawFrame);
|
||||
}
|
||||
|
||||
return function disposeWespLogoLoader() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
cancelAnimationFrame(rafId);
|
||||
globalThis.removeEventListener("resize", resize);
|
||||
root.classList.add("wesp-logo-loader--exiting");
|
||||
window.setTimeout(() => {
|
||||
root.classList.add("wesp-logo-loader--hidden");
|
||||
window.setTimeout(() => {
|
||||
if (root.isConnected) root.remove();
|
||||
}, 480);
|
||||
}, 520);
|
||||
};
|
||||
}
|
||||
|
||||
export async function showWespLogoLoader(options = {}) {
|
||||
const root = document.createElement("div");
|
||||
root.id = options.id ?? "wespBootLoader";
|
||||
root.className = "wesp-logo-loader";
|
||||
root.setAttribute("aria-busy", "true");
|
||||
root.setAttribute("aria-label", options.label ?? "Загрузка");
|
||||
document.body.appendChild(root);
|
||||
return mountWespLogoLoader(root, options);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Блик по буквам логотипа в navbar (тёмная тема) — тот же drawShimmer, что в wesp-logo-loader.
|
||||
*/
|
||||
(function () {
|
||||
const LOGO_SRC = "/static/logo2.png";
|
||||
const CYCLE_MS = 3000;
|
||||
const SHIMMER_MS = 1000;
|
||||
const START_DELAY_MS = 350;
|
||||
|
||||
const mounts = new WeakMap();
|
||||
|
||||
function isDarkTheme() {
|
||||
return document.documentElement.getAttribute("data-theme") === "dark";
|
||||
}
|
||||
|
||||
function prefersReducedMotion() {
|
||||
return globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false;
|
||||
}
|
||||
|
||||
function loadImage(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error(`Failed to load: ${src}`));
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
function disposeMount(link) {
|
||||
const state = mounts.get(link);
|
||||
if (!state) return;
|
||||
state.disposed = true;
|
||||
if (state.rafId) cancelAnimationFrame(state.rafId);
|
||||
state.canvas?.remove();
|
||||
link.classList.remove("nav-logo-link--canvas-shimmer");
|
||||
mounts.delete(link);
|
||||
}
|
||||
|
||||
async function mountCanvasShimmer(link) {
|
||||
if (!isDarkTheme() || prefersReducedMotion()) return;
|
||||
disposeMount(link);
|
||||
|
||||
let logoModule;
|
||||
try {
|
||||
logoModule = await import("/static/js/wesp-logo-loader.js?v=2");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const { createNavLogoShimmerBuffer, drawShimmer } = logoModule;
|
||||
let img;
|
||||
try {
|
||||
img = await loadImage(LOGO_SRC);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.className = "nav-logo__shimmer-canvas";
|
||||
canvas.setAttribute("aria-hidden", "true");
|
||||
link.classList.add("nav-logo-link--canvas-shimmer");
|
||||
link.appendChild(canvas);
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
const state = {
|
||||
disposed: false,
|
||||
rafId: 0,
|
||||
canvas,
|
||||
logoCanvas: null,
|
||||
layout: null,
|
||||
startAt: performance.now() + START_DELAY_MS,
|
||||
lastW: 0,
|
||||
lastH: 0,
|
||||
};
|
||||
mounts.set(link, state);
|
||||
|
||||
function rebuildBuffer() {
|
||||
const w = Math.max(1, Math.round(link.clientWidth));
|
||||
const h = Math.max(1, Math.round(link.clientHeight));
|
||||
if (w === state.lastW && h === state.lastH && state.logoCanvas) return;
|
||||
|
||||
state.lastW = w;
|
||||
state.lastH = h;
|
||||
const dpr = Math.min(globalThis.devicePixelRatio || 1, 2);
|
||||
canvas.width = Math.round(w * dpr);
|
||||
canvas.height = Math.round(h * dpr);
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const built = createNavLogoShimmerBuffer(img, w, h, [255, 255, 255]);
|
||||
state.logoCanvas = built.logoCanvas;
|
||||
state.layout = built.layout;
|
||||
}
|
||||
|
||||
function tick(now) {
|
||||
if (state.disposed) return;
|
||||
if (!isDarkTheme()) {
|
||||
disposeMount(link);
|
||||
return;
|
||||
}
|
||||
|
||||
rebuildBuffer();
|
||||
|
||||
const w = state.lastW;
|
||||
const h = state.lastH;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const elapsed = now - state.startAt;
|
||||
if (elapsed >= 0 && state.logoCanvas && state.layout) {
|
||||
const cycle = elapsed % CYCLE_MS;
|
||||
if (cycle < SHIMMER_MS) {
|
||||
const shimmerT = cycle / SHIMMER_MS;
|
||||
ctx.drawImage(state.logoCanvas, 0, 0, w, h);
|
||||
drawShimmer(ctx, state.layout, state.logoCanvas, shimmerT, false);
|
||||
}
|
||||
}
|
||||
|
||||
state.rafId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
rebuildBuffer();
|
||||
state.rafId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (prefersReducedMotion()) return;
|
||||
|
||||
document
|
||||
.querySelectorAll(".mobile-navbar .nav-logo-link, .mobile-brand .nav-logo-link")
|
||||
.forEach((link) => {
|
||||
if (isDarkTheme()) {
|
||||
mountCanvasShimmer(link);
|
||||
} else {
|
||||
disposeMount(link);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function onThemeChange() {
|
||||
init();
|
||||
}
|
||||
|
||||
window.WespNavLogoShimmer = { init, disposeMount };
|
||||
|
||||
const themeObserver = new MutationObserver(onThemeChange);
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-theme"],
|
||||
});
|
||||
|
||||
/* init вызывается из wesp-zootech-nav.js после вставки navbar и из wesp-page-enter после enter-done */
|
||||
})();
|
||||
@@ -0,0 +1,68 @@
|
||||
export const PRELOAD_FEED_DISPENSERS_KEY = "wesp_preload_feed_dispensers_v1";
|
||||
export const PRELOAD_FEED_DISPENSERS_MAX_AGE_MS = 120_000;
|
||||
export const LOGIN_TRANSITION_KEY = "wesp_login_transition_v1";
|
||||
export const LOGIN_TRANSITION_MAX_AGE_MS = 120_000;
|
||||
|
||||
function readStoredEntry(key, maxAgeMs) {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || Date.now() - parsed.storedAt > maxAgeMs) {
|
||||
sessionStorage.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function markLoginTransition() {
|
||||
try {
|
||||
sessionStorage.setItem(LOGIN_TRANSITION_KEY, JSON.stringify({
|
||||
storedAt: Date.now(),
|
||||
}));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function storePreloadedApiPayload(key, payload) {
|
||||
try {
|
||||
sessionStorage.setItem(key, JSON.stringify({
|
||||
storedAt: Date.now(),
|
||||
payload,
|
||||
}));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function peekPreloadedApiPayload(key, maxAgeMs = PRELOAD_FEED_DISPENSERS_MAX_AGE_MS) {
|
||||
const entry = readStoredEntry(key, maxAgeMs);
|
||||
return entry?.payload ?? null;
|
||||
}
|
||||
|
||||
export function clearPreloadedApiPayload(key) {
|
||||
try {
|
||||
sessionStorage.removeItem(key);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function clearRecipesBootOverlay() {
|
||||
try {
|
||||
sessionStorage.removeItem(LOGIN_TRANSITION_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
document.documentElement.classList.remove("wesp-login-boot-pending");
|
||||
|
||||
const overlay = document.getElementById("wesp-recipes-boot");
|
||||
if (overlay) overlay.remove();
|
||||
|
||||
window.WespPageEnter?.reveal?.();
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Мягкая поэтапная анимация zootech-страниц: navbar → main → блоки.
|
||||
* html.wesp-page-enter-pending ставится inline в <head> до первого paint.
|
||||
*/
|
||||
(function () {
|
||||
const MAX_ITEMS = 8;
|
||||
let held = false;
|
||||
let revealed = false;
|
||||
|
||||
function prefersReducedMotion() {
|
||||
return globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false;
|
||||
}
|
||||
|
||||
function isGridListPage() {
|
||||
const body = document.body;
|
||||
if (!body) return false;
|
||||
return (
|
||||
body.classList.contains("components-page") ||
|
||||
body.classList.contains("feed-dispensers-page") ||
|
||||
body.classList.contains("reports-page")
|
||||
);
|
||||
}
|
||||
|
||||
function markEnterItems() {
|
||||
const html = document.documentElement;
|
||||
if (isGridListPage()) {
|
||||
html.classList.add("wesp-page-enter-grid-page");
|
||||
return;
|
||||
}
|
||||
html.classList.remove("wesp-page-enter-grid-page");
|
||||
|
||||
const root = document.querySelector(".wesp-page-main");
|
||||
if (!root) return;
|
||||
|
||||
let items = Array.from(root.querySelectorAll("[data-wesp-enter]"));
|
||||
if (!items.length) {
|
||||
items = Array.from(root.children).filter((el) => el.nodeType === 1);
|
||||
}
|
||||
|
||||
items.slice(0, MAX_ITEMS).forEach((el, i) => {
|
||||
el.classList.add("wesp-page-enter-item");
|
||||
el.style.setProperty("--wesp-enter-i", String(i));
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-wesp-enter].app-footer, footer[data-wesp-enter]").forEach((el, i) => {
|
||||
if (el.classList.contains("wesp-page-enter-item")) return;
|
||||
el.classList.add("wesp-page-enter-item");
|
||||
el.style.setProperty("--wesp-enter-i", String(items.length + i));
|
||||
});
|
||||
}
|
||||
|
||||
function finalizeReveal() {
|
||||
if (revealed) return;
|
||||
revealed = true;
|
||||
const html = document.documentElement;
|
||||
html.classList.remove("wesp-page-enter-pending");
|
||||
html.classList.add("wesp-page-enter-ready");
|
||||
|
||||
let settled = false;
|
||||
let fallbackTimer = 0;
|
||||
|
||||
const settleDone = () => {
|
||||
if (settled || !html.classList.contains("wesp-page-enter-ready")) return;
|
||||
settled = true;
|
||||
if (fallbackTimer) window.clearTimeout(fallbackTimer);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
html.classList.remove("wesp-page-enter-ready");
|
||||
html.classList.add("wesp-page-enter-done");
|
||||
requestAnimationFrame(() => {
|
||||
globalThis.WespNavLogoShimmer?.init?.();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const items = document.querySelectorAll(".wesp-page-enter-item");
|
||||
let pending = items.length;
|
||||
if (pending === 0) {
|
||||
settleDone();
|
||||
return;
|
||||
}
|
||||
|
||||
const onAnimEnd = (event) => {
|
||||
if (event.animationName !== "wesp-enter-fade") return;
|
||||
pending -= 1;
|
||||
if (pending <= 0) settleDone();
|
||||
};
|
||||
|
||||
items.forEach((el) => {
|
||||
el.addEventListener("animationend", onAnimEnd, { once: true });
|
||||
});
|
||||
|
||||
const fallbackMs =
|
||||
80 + (MAX_ITEMS - 1) * 55 + 350 + 80;
|
||||
fallbackTimer = window.setTimeout(settleDone, fallbackMs);
|
||||
}
|
||||
|
||||
function revealPageMain() {
|
||||
if (revealed || held) return;
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (!held && !revealed) finalizeReveal();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function holdPageMain() {
|
||||
held = true;
|
||||
}
|
||||
|
||||
function releaseAndReveal() {
|
||||
held = false;
|
||||
if (!revealed) revealPageMain();
|
||||
}
|
||||
|
||||
function init() {
|
||||
markEnterItems();
|
||||
|
||||
if (prefersReducedMotion()) {
|
||||
finalizeReveal();
|
||||
document.documentElement.classList.add("wesp-page-enter-done");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!held) {
|
||||
revealPageMain();
|
||||
}
|
||||
}
|
||||
|
||||
window.WespPageEnter = {
|
||||
init,
|
||||
hold: holdPageMain,
|
||||
reveal: releaseAndReveal,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,45 @@
|
||||
/* Синхронная ранняя установка data-theme + data-high-contrast + критические стили. */
|
||||
(function () {
|
||||
var KEY = "theme";
|
||||
var HC_KEY = "wespHighContrast";
|
||||
var saved = localStorage.getItem(KEY);
|
||||
var theme =
|
||||
saved === "dark" || saved === "light" || saved === "organic" ? saved : "dark";
|
||||
var root = document.documentElement;
|
||||
root.dataset.theme = theme;
|
||||
|
||||
var hcSaved = localStorage.getItem(HC_KEY);
|
||||
var highContrast =
|
||||
hcSaved === "1"
|
||||
? true
|
||||
: hcSaved === "0"
|
||||
? false
|
||||
: !!(window.matchMedia && window.matchMedia("(prefers-contrast: more)").matches);
|
||||
root.dataset.highContrast = highContrast ? "true" : "false";
|
||||
|
||||
var ORGANIC_LOGO_FILTER =
|
||||
"brightness(0) saturate(100%) invert(43%) sepia(19%) saturate(747%) " +
|
||||
"hue-rotate(118deg) brightness(92%) contrast(87%)";
|
||||
|
||||
var ORGANIC_LOGO_FILTER_HC =
|
||||
"brightness(0) saturate(100%) invert(28%) sepia(22%) saturate(900%) " +
|
||||
"hue-rotate(118deg) brightness(88%) contrast(96%)";
|
||||
|
||||
var organicLogoFilter =
|
||||
theme === "organic" && highContrast ? ORGANIC_LOGO_FILTER_HC : ORGANIC_LOGO_FILTER;
|
||||
|
||||
var css =
|
||||
'html{background:#f5f7fa}' +
|
||||
'html[data-theme="dark"]{background:#12181b}' +
|
||||
'html[data-theme="organic"]{background:#f6f6f4}' +
|
||||
'html[data-theme="organic"] .nav-brand .logo.logo--light-theme,' +
|
||||
'html[data-theme="organic"] .mobile-brand .logo.logo--light-theme{' +
|
||||
"filter:" +
|
||||
organicLogoFilter +
|
||||
"!important}";
|
||||
|
||||
var el = document.createElement("style");
|
||||
el.id = "wesp-theme-critical";
|
||||
el.textContent = css;
|
||||
document.head.appendChild(el);
|
||||
})();
|
||||
@@ -0,0 +1,143 @@
|
||||
/** Общая тема зоотех-страниц: localStorage + переключатель #wespThemePicker */
|
||||
export const THEME_KEY = "theme";
|
||||
export const HIGH_CONTRAST_KEY = "wespHighContrast";
|
||||
|
||||
export const THEMES = ["light", "organic", "dark"];
|
||||
|
||||
/** @deprecated Админка всегда ultra-dark; ключ оставлен для совместимости zootech-тем. */
|
||||
export const ADMIN_ULTRA_DARK_KEY = "wespAdminUltraDark";
|
||||
|
||||
const THEME_LABELS = {
|
||||
light: "Светлая",
|
||||
organic: "Зелёная",
|
||||
dark: "Тёмная",
|
||||
};
|
||||
|
||||
export function getTheme() {
|
||||
const saved = localStorage.getItem(THEME_KEY);
|
||||
if (THEMES.includes(saved)) return saved;
|
||||
return "dark";
|
||||
}
|
||||
|
||||
/** Ручной выбор в настройках; иначе — системный prefers-contrast. */
|
||||
export function resolveHighContrast() {
|
||||
try {
|
||||
const saved = localStorage.getItem(HIGH_CONTRAST_KEY);
|
||||
if (saved === "1") return true;
|
||||
if (saved === "0") return false;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return globalThis.matchMedia?.("(prefers-contrast: more)")?.matches ?? false;
|
||||
}
|
||||
|
||||
export function getHighContrast() {
|
||||
return resolveHighContrast();
|
||||
}
|
||||
|
||||
export function applyHighContrast(on) {
|
||||
document.documentElement.dataset.highContrast = on ? "true" : "false";
|
||||
}
|
||||
|
||||
export function setHighContrast(on) {
|
||||
try {
|
||||
localStorage.setItem(HIGH_CONTRAST_KEY, on ? "1" : "0");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
applyHighContrast(!!on);
|
||||
syncHighContrastUI();
|
||||
}
|
||||
|
||||
export function applyTheme(theme) {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
}
|
||||
|
||||
export function setTheme(theme) {
|
||||
if (!THEMES.includes(theme)) return;
|
||||
if (theme === "light" || theme === "organic") {
|
||||
try {
|
||||
localStorage.removeItem(ADMIN_ULTRA_DARK_KEY);
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
localStorage.setItem(THEME_KEY, theme);
|
||||
applyTheme(theme);
|
||||
syncThemePickerUI();
|
||||
}
|
||||
|
||||
function syncThemePickerUI() {
|
||||
const mount = document.getElementById("wespThemePicker");
|
||||
if (!mount) return;
|
||||
const current = getTheme();
|
||||
mount.querySelectorAll("[data-theme-value]").forEach((btn) => {
|
||||
const active = btn.dataset.themeValue === current;
|
||||
btn.classList.toggle("is-active", active);
|
||||
btn.setAttribute("aria-checked", active ? "true" : "false");
|
||||
});
|
||||
}
|
||||
|
||||
function syncHighContrastUI() {
|
||||
const el = document.getElementById("wespHighContrastToggle");
|
||||
if (!el) return;
|
||||
const on = getHighContrast();
|
||||
el.checked = on;
|
||||
el.setAttribute("aria-checked", on ? "true" : "false");
|
||||
}
|
||||
|
||||
/** Сегментированный переключатель тем (3 варианта). */
|
||||
export function initThemePicker() {
|
||||
const mount = document.getElementById("wespThemePicker");
|
||||
if (!mount) return;
|
||||
|
||||
if (mount.dataset.bound !== "1") {
|
||||
mount.dataset.bound = "1";
|
||||
mount.classList.add("theme-picker");
|
||||
mount.innerHTML = THEMES.map(
|
||||
(id) =>
|
||||
`<button type="button" class="theme-picker__btn" data-theme-value="${id}" role="radio" aria-checked="false">${THEME_LABELS[id]}</button>`
|
||||
).join("");
|
||||
mount.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("[data-theme-value]");
|
||||
if (!btn) return;
|
||||
setTheme(btn.dataset.themeValue);
|
||||
});
|
||||
}
|
||||
|
||||
syncThemePickerUI();
|
||||
}
|
||||
|
||||
/** Переключатель усиленного контраста (под выбором темы). */
|
||||
export function initHighContrastToggle() {
|
||||
const el = document.getElementById("wespHighContrastToggle");
|
||||
if (!el) return;
|
||||
|
||||
if (el.dataset.bound !== "1") {
|
||||
el.dataset.bound = "1";
|
||||
el.addEventListener("change", () => setHighContrast(el.checked));
|
||||
}
|
||||
|
||||
syncHighContrastUI();
|
||||
}
|
||||
|
||||
/** @deprecated Используйте initThemePicker. Оставлен для обратной совместимости. */
|
||||
export function initThemeToggle() {
|
||||
if (document.getElementById("wespThemePicker")) {
|
||||
initThemePicker();
|
||||
return;
|
||||
}
|
||||
const el = document.getElementById("darkThemeToggle");
|
||||
if (!el) return;
|
||||
|
||||
if (el.dataset.bound === "1") {
|
||||
el.checked = document.documentElement.dataset.theme === "dark";
|
||||
return;
|
||||
}
|
||||
|
||||
el.dataset.bound = "1";
|
||||
el.checked = document.documentElement.dataset.theme === "dark";
|
||||
el.addEventListener("change", () => {
|
||||
setTheme(el.checked ? "dark" : "light");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
/**
|
||||
* Фоновый poll статуса обновлений + баннер «Обновить / Позже».
|
||||
* WespUpdateNotifier.init({ dialog: WespDialog | WespKioskDialog });
|
||||
*/
|
||||
(function (global) {
|
||||
var DISMISS_KEY = "wesp_update_dismissed";
|
||||
var DEFAULT_POLL_MS = 300000;
|
||||
var PROGRESS_POLL_MS = 500;
|
||||
|
||||
var opts = {};
|
||||
var pollTimer = null;
|
||||
var progressTimer = null;
|
||||
var lastStatus = null;
|
||||
var lastProgress = null;
|
||||
|
||||
function ensureDom() {
|
||||
if (document.getElementById("wespUpdateBanner")) return;
|
||||
|
||||
var banner = document.createElement("div");
|
||||
banner.id = "wespUpdateBanner";
|
||||
banner.setAttribute("role", "region");
|
||||
banner.setAttribute("aria-label", "Доступно обновление");
|
||||
banner.innerHTML =
|
||||
'<div class="wesp-update-banner-inner">' +
|
||||
'<div class="wesp-update-banner-text" id="wespUpdateBannerText"></div>' +
|
||||
'<div class="wesp-update-banner-changelog" id="wespUpdateBannerChangelog"></div>' +
|
||||
'<div class="wesp-update-banner-actions">' +
|
||||
'<button type="button" class="wesp-update-banner-btn" id="wespUpdateBannerDetails">Подробнее</button>' +
|
||||
'<button type="button" class="wesp-update-banner-btn" id="wespUpdateBannerLater">Позже</button>' +
|
||||
'<button type="button" class="wesp-update-banner-btn wesp-update-banner-btn--primary" id="wespUpdateBannerInstall">Обновить</button>' +
|
||||
"</div></div>";
|
||||
document.body.appendChild(banner);
|
||||
|
||||
var overlay = document.createElement("div");
|
||||
overlay.id = "wespUpdateOverlay";
|
||||
overlay.setAttribute("aria-hidden", "true");
|
||||
overlay.innerHTML =
|
||||
'<div class="wesp-update-overlay-panel" role="alertdialog" aria-modal="true">' +
|
||||
'<h2 class="wesp-update-overlay-title" id="wespUpdateOverlayTitle">Идёт обновление</h2>' +
|
||||
'<p class="wesp-update-overlay-body" id="wespUpdateOverlayBody">Подготовка…</p>' +
|
||||
'<div class="wesp-update-progress-wrap" id="wespUpdateProgressWrap">' +
|
||||
'<div class="wesp-update-progress-track">' +
|
||||
'<div class="wesp-update-progress-bar" id="wespUpdateProgressBar"></div>' +
|
||||
"</div>" +
|
||||
'<div class="wesp-update-progress-meta">' +
|
||||
'<span class="wesp-update-progress-percent" id="wespUpdateProgressPercent">0%</span>' +
|
||||
'<span class="wesp-update-progress-detail" id="wespUpdateProgressDetail"></span>' +
|
||||
"</div>" +
|
||||
"</div>" +
|
||||
"</div>";
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
document.getElementById("wespUpdateBannerLater").addEventListener("click", dismissBanner);
|
||||
document.getElementById("wespUpdateBannerDetails").addEventListener("click", toggleDetails);
|
||||
document.getElementById("wespUpdateBannerInstall").addEventListener("click", onInstallClick);
|
||||
}
|
||||
|
||||
function isDismissed(version) {
|
||||
try {
|
||||
return sessionStorage.getItem(DISMISS_KEY) === String(version || "");
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function dismissBanner() {
|
||||
if (lastStatus && lastStatus.pending_version) {
|
||||
try {
|
||||
sessionStorage.setItem(DISMISS_KEY, String(lastStatus.pending_version));
|
||||
} catch (e) {}
|
||||
}
|
||||
hideBanner();
|
||||
}
|
||||
|
||||
function hideBanner() {
|
||||
var el = document.getElementById("wespUpdateBanner");
|
||||
if (el) {
|
||||
el.classList.remove("wesp-update-banner--visible", "wesp-update-banner--expanded");
|
||||
}
|
||||
}
|
||||
|
||||
function showBanner(status) {
|
||||
if (!status || !status.update_available || !status.pending_version) {
|
||||
hideBanner();
|
||||
return;
|
||||
}
|
||||
if (isDismissed(status.pending_version)) {
|
||||
hideBanner();
|
||||
return;
|
||||
}
|
||||
ensureDom();
|
||||
var el = document.getElementById("wespUpdateBanner");
|
||||
var text = document.getElementById("wespUpdateBannerText");
|
||||
var changelog = document.getElementById("wespUpdateBannerChangelog");
|
||||
if (!el || !text) return;
|
||||
|
||||
text.innerHTML =
|
||||
"Доступна версия <strong>" +
|
||||
escapeHtml(status.pending_version) +
|
||||
"</strong>" +
|
||||
(status.pending_name ? " — " + escapeHtml(status.pending_name) : "") +
|
||||
". Сейчас: <strong>" +
|
||||
escapeHtml(status.current_version || "—") +
|
||||
"</strong>.";
|
||||
|
||||
if (changelog) {
|
||||
changelog.textContent = status.pending_body || "";
|
||||
changelog.style.display = status.pending_body ? "" : "none";
|
||||
}
|
||||
|
||||
var detailsBtn = document.getElementById("wespUpdateBannerDetails");
|
||||
if (detailsBtn) {
|
||||
detailsBtn.style.display = status.pending_body ? "" : "none";
|
||||
}
|
||||
|
||||
el.classList.add("wesp-update-banner--visible");
|
||||
}
|
||||
|
||||
function toggleDetails() {
|
||||
var el = document.getElementById("wespUpdateBanner");
|
||||
if (el) el.classList.toggle("wesp-update-banner--expanded");
|
||||
}
|
||||
|
||||
function showInstallingOverlay(message, progress) {
|
||||
ensureDom();
|
||||
var overlay = document.getElementById("wespUpdateOverlay");
|
||||
if (overlay) {
|
||||
overlay.classList.add("wesp-update-overlay--visible");
|
||||
overlay.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
if (message) {
|
||||
var bodyEl = document.getElementById("wespUpdateOverlayBody");
|
||||
if (bodyEl) bodyEl.textContent = message;
|
||||
}
|
||||
applyProgressUi(progress);
|
||||
hideBanner();
|
||||
}
|
||||
|
||||
function hideInstallingOverlay() {
|
||||
var overlay = document.getElementById("wespUpdateOverlay");
|
||||
if (overlay) {
|
||||
overlay.classList.remove("wesp-update-overlay--visible");
|
||||
overlay.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
}
|
||||
|
||||
function applyProgressUi(progress) {
|
||||
if (!progress || typeof progress !== "object") return;
|
||||
lastProgress = progress;
|
||||
|
||||
var titleEl = document.getElementById("wespUpdateOverlayTitle");
|
||||
var bodyEl = document.getElementById("wespUpdateOverlayBody");
|
||||
var barEl = document.getElementById("wespUpdateProgressBar");
|
||||
var pctEl = document.getElementById("wespUpdateProgressPercent");
|
||||
var detailEl = document.getElementById("wespUpdateProgressDetail");
|
||||
|
||||
var pct = Number(progress.percent);
|
||||
if (!Number.isFinite(pct)) pct = 0;
|
||||
pct = Math.max(0, Math.min(100, Math.round(pct)));
|
||||
|
||||
if (titleEl && progress.target_version) {
|
||||
titleEl.textContent = "Обновление до версии " + progress.target_version;
|
||||
}
|
||||
if (bodyEl && progress.message) {
|
||||
bodyEl.textContent = progress.message;
|
||||
}
|
||||
if (barEl) {
|
||||
barEl.style.width = pct + "%";
|
||||
barEl.setAttribute("aria-valuenow", String(pct));
|
||||
}
|
||||
if (pctEl) pctEl.textContent = pct + "%";
|
||||
if (detailEl) {
|
||||
detailEl.textContent = progress.detail || "";
|
||||
detailEl.style.display = progress.detail ? "" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
function stopProgressPolling() {
|
||||
if (progressTimer) {
|
||||
clearInterval(progressTimer);
|
||||
progressTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startProgressPolling() {
|
||||
stopProgressPolling();
|
||||
progressTimer = setInterval(function () {
|
||||
fetchStatus().then(function (status) {
|
||||
if (!status) return;
|
||||
if (status.update_progress) {
|
||||
applyProgressUi(status.update_progress);
|
||||
} else if (status.is_updating) {
|
||||
applyProgressUi({
|
||||
message: "Выполняется обновление…",
|
||||
percent: lastProgress ? lastProgress.percent : 5,
|
||||
});
|
||||
}
|
||||
});
|
||||
}, PROGRESS_POLL_MS);
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function fetchStatus() {
|
||||
var url = opts.statusUrl || "/api/updates/status";
|
||||
return fetch(url, { credentials: "same-origin", cache: "no-store" }).then(function (r) {
|
||||
if (r.status === 401 || r.status === 403) return null;
|
||||
return r.json().catch(function () {
|
||||
return null;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function applyStatus(status) {
|
||||
if (!status || typeof status !== "object") return;
|
||||
lastStatus = status;
|
||||
if (status.update_available && status.pending_version) {
|
||||
try {
|
||||
var dismissed = sessionStorage.getItem(DISMISS_KEY);
|
||||
if (dismissed && dismissed !== String(status.pending_version)) {
|
||||
sessionStorage.removeItem(DISMISS_KEY);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
showBanner(status);
|
||||
}
|
||||
|
||||
function pollOnce() {
|
||||
return fetchStatus().then(function (status) {
|
||||
applyStatus(status);
|
||||
});
|
||||
}
|
||||
|
||||
function onInstallClick() {
|
||||
var dialog = opts.dialog;
|
||||
if (!dialog || typeof dialog.confirm !== "function") {
|
||||
if (!global.confirm("Система перезапустится. Продолжить?")) return;
|
||||
doInstall();
|
||||
return;
|
||||
}
|
||||
var msg =
|
||||
"Будет загружена и установлена версия " +
|
||||
(lastStatus && lastStatus.pending_version ? lastStatus.pending_version : "") +
|
||||
".\n\nСистема перезапустится. Продолжить?";
|
||||
dialog
|
||||
.confirm(msg, {
|
||||
title: "Обновление WESP",
|
||||
okText: "Обновить",
|
||||
cancelText: "Отмена",
|
||||
danger: true,
|
||||
})
|
||||
.then(function (ok) {
|
||||
if (ok) doInstall();
|
||||
});
|
||||
}
|
||||
|
||||
function waitForServerAndReload() {
|
||||
var url = opts.statusUrl || "/api/updates/status";
|
||||
var attempts = 0;
|
||||
var maxAttempts = 90;
|
||||
var intervalMs = 2000;
|
||||
|
||||
showInstallingOverlay(
|
||||
"Обновление установлено. Ожидаем перезапуск сервера — страница обновится автоматически.",
|
||||
{ message: "Ожидание перезапуска сервера…", percent: 100, detail: null }
|
||||
);
|
||||
|
||||
function scheduleNext() {
|
||||
if (attempts >= maxAttempts) {
|
||||
var bodyEl = document.getElementById("wespUpdateOverlayBody");
|
||||
if (bodyEl) {
|
||||
bodyEl.innerHTML =
|
||||
"Сервер долго не отвечает. Если перезапуск уже завершился, " +
|
||||
'<button type="button" class="wesp-update-banner-btn wesp-update-banner-btn--primary" id="wespUpdateManualReload">Обновить страницу</button>';
|
||||
var btn = document.getElementById("wespUpdateManualReload");
|
||||
if (btn) btn.addEventListener("click", function () { global.location.reload(); });
|
||||
}
|
||||
return;
|
||||
}
|
||||
setTimeout(tick, intervalMs);
|
||||
}
|
||||
|
||||
function showRollbackOverlay(state) {
|
||||
var ver = (state && (state.previous_version || state.message)) || "—";
|
||||
var msg =
|
||||
(state && state.message) ||
|
||||
"Обновление отменено. Восстановлена предыдущая версия.";
|
||||
showInstallingOverlay(msg, {
|
||||
message: msg,
|
||||
percent: 100,
|
||||
target_version: ver,
|
||||
});
|
||||
var bar = document.getElementById("wespUpdateProgressBar");
|
||||
var panel = document.querySelector("#wespUpdateOverlay .wesp-update-overlay-panel");
|
||||
if (bar) bar.classList.add("wesp-update-progress-bar--error");
|
||||
if (panel) panel.classList.add("wesp-update-overlay-panel--rollback");
|
||||
var bodyEl = document.getElementById("wespUpdateOverlayBody");
|
||||
if (bodyEl) {
|
||||
bodyEl.innerHTML =
|
||||
escapeHtml(msg) +
|
||||
' <button type="button" class="wesp-update-banner-btn wesp-update-banner-btn--primary" id="wespUpdateManualReload">Обновить страницу</button>';
|
||||
var btn = document.getElementById("wespUpdateManualReload");
|
||||
if (btn) btn.addEventListener("click", function () { global.location.reload(); });
|
||||
}
|
||||
}
|
||||
|
||||
function tick() {
|
||||
attempts += 1;
|
||||
applyProgressUi({
|
||||
message: "Ожидание перезапуска сервера…",
|
||||
percent: 100,
|
||||
detail: "~" + attempts * 2 + " с",
|
||||
});
|
||||
fetch(url, { credentials: "same-origin", cache: "no-store" })
|
||||
.then(function (r) {
|
||||
return r.json().catch(function () { return null; }).then(function (body) {
|
||||
return { ok: r.ok, status: r.status, body: body };
|
||||
});
|
||||
})
|
||||
.then(function (res) {
|
||||
var st = res.body && res.body.last_update_state;
|
||||
if (st && st.status === "rolled_back") {
|
||||
showRollbackOverlay(st);
|
||||
return;
|
||||
}
|
||||
if (res.ok || res.status === 401 || res.status === 403) {
|
||||
global.location.reload();
|
||||
return;
|
||||
}
|
||||
scheduleNext();
|
||||
})
|
||||
.catch(function () {
|
||||
scheduleNext();
|
||||
});
|
||||
}
|
||||
|
||||
setTimeout(tick, 3000);
|
||||
}
|
||||
|
||||
function doInstall() {
|
||||
var targetVersion = lastStatus && lastStatus.pending_version ? lastStatus.pending_version : "";
|
||||
showInstallingOverlay("Подготовка к обновлению…", {
|
||||
message: "Подготовка к обновлению…",
|
||||
percent: 0,
|
||||
target_version: targetVersion,
|
||||
});
|
||||
startProgressPolling();
|
||||
|
||||
var url = opts.installUrl || "/api/updates/install";
|
||||
fetch(url, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: "{}" })
|
||||
.then(function (r) {
|
||||
return r.json().catch(function () {
|
||||
return {};
|
||||
}).then(function (body) {
|
||||
return { ok: r.ok, body: body };
|
||||
});
|
||||
})
|
||||
.then(function (res) {
|
||||
stopProgressPolling();
|
||||
if (!res.ok) {
|
||||
hideInstallingOverlay();
|
||||
var errMsg = (res.body && (res.body.error || res.body.message)) || "Не удалось установить обновление";
|
||||
if (opts.dialog && opts.dialog.alert) {
|
||||
opts.dialog.alert(errMsg, { title: "Ошибка обновления", variant: "danger" });
|
||||
} else {
|
||||
global.alert(errMsg);
|
||||
}
|
||||
pollOnce();
|
||||
return;
|
||||
}
|
||||
waitForServerAndReload();
|
||||
})
|
||||
.catch(function () {
|
||||
stopProgressPolling();
|
||||
hideInstallingOverlay();
|
||||
if (opts.dialog && opts.dialog.alert) {
|
||||
opts.dialog.alert("Сетевая ошибка при установке обновления.", { title: "Ошибка", variant: "danger" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function init(options) {
|
||||
opts = options || {};
|
||||
if (!opts.dialog) {
|
||||
opts.dialog = global.WespDialog || global.WespKioskDialog || null;
|
||||
}
|
||||
ensureDom();
|
||||
pollOnce();
|
||||
var interval = Number(opts.pollIntervalMs);
|
||||
if (!Number.isFinite(interval) || interval < 60000) interval = DEFAULT_POLL_MS;
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
pollTimer = setInterval(pollOnce, interval);
|
||||
}
|
||||
|
||||
global.WespUpdateNotifier = {
|
||||
init: init,
|
||||
pollOnce: pollOnce,
|
||||
dismissBanner: dismissBanner,
|
||||
};
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
@@ -0,0 +1,61 @@
|
||||
/** Правила пароля — как в админке и setup wizard. Логин без ограничений по символам. */
|
||||
export const DEFAULT_PASSWORD_MIN_LEN = 8;
|
||||
export const LOGIN_MAX_LEN = 64;
|
||||
export const PASSWORD_COMPLEXITY_MSG =
|
||||
"Пароль не должен состоять только из цифр или только из букв. Смешайте буквы и цифры.";
|
||||
|
||||
export function validateNewUserCredentials({
|
||||
login,
|
||||
password,
|
||||
confirmPassword,
|
||||
passwordMinLen = DEFAULT_PASSWORD_MIN_LEN,
|
||||
label = "",
|
||||
}) {
|
||||
const prefix = label ? `${label}: ` : "";
|
||||
const trimmedLogin = String(login || "").trim();
|
||||
if (!trimmedLogin) return `${prefix}укажите логин.`;
|
||||
if (trimmedLogin.length > LOGIN_MAX_LEN) {
|
||||
return `${prefix}логин не длиннее ${LOGIN_MAX_LEN} символов.`;
|
||||
}
|
||||
if (!password) return `${prefix}укажите пароль.`;
|
||||
if (password !== confirmPassword) return `${prefix}пароли не совпадают.`;
|
||||
if (password.length < passwordMinLen) {
|
||||
return `${prefix}пароль не короче ${passwordMinLen} символов.`;
|
||||
}
|
||||
if (/^\d+$/.test(password) || /^[a-zA-Z]+$/.test(password)) {
|
||||
return `${prefix}${PASSWORD_COMPLEXITY_MSG}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateCredentialChange({
|
||||
oldLogin,
|
||||
oldPassword,
|
||||
newLogin,
|
||||
newPassword,
|
||||
confirmPassword,
|
||||
passwordMinLen = DEFAULT_PASSWORD_MIN_LEN,
|
||||
}) {
|
||||
if (!oldLogin || !oldPassword) {
|
||||
return "Укажите текущий логин и пароль";
|
||||
}
|
||||
return validateNewUserCredentials({
|
||||
login: newLogin,
|
||||
password: newPassword,
|
||||
confirmPassword,
|
||||
passwordMinLen,
|
||||
});
|
||||
}
|
||||
|
||||
export function ensureCredentialsFormHints() {}
|
||||
|
||||
if (typeof globalThis !== "undefined") {
|
||||
globalThis.WespUserCredentials = {
|
||||
DEFAULT_PASSWORD_MIN_LEN,
|
||||
LOGIN_MAX_LEN,
|
||||
PASSWORD_COMPLEXITY_MSG,
|
||||
validateCredentialChange,
|
||||
validateNewUserCredentials,
|
||||
ensureCredentialsFormHints,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Безопасные тексты для пользователя: без HTTP-кодов, stack trace и прочей техники.
|
||||
*/
|
||||
(function (global) {
|
||||
const DEFAULT_ERROR_MESSAGE = "Не удалось выполнить операцию. Попробуйте ещё раз.";
|
||||
|
||||
const TECHNICAL_MESSAGE_PATTERNS = [
|
||||
/\bHTTP\s*\d{3}\b/i,
|
||||
/^\d{3}$/,
|
||||
/ошибка\s*\d{3}/i,
|
||||
/status:\s*\d+/i,
|
||||
/Failed to fetch/i,
|
||||
/NetworkError/i,
|
||||
/Load failed/i,
|
||||
/SyntaxError/i,
|
||||
/TypeError/i,
|
||||
/ReferenceError/i,
|
||||
/RangeError/i,
|
||||
/Traceback/i,
|
||||
/\bException\b/i,
|
||||
/UnicodeEncodeError/i,
|
||||
/Internal Server Error/i,
|
||||
/<html[\s>]/i,
|
||||
/<!DOCTYPE/i,
|
||||
/^Error:/i,
|
||||
/\.py:\d+/i,
|
||||
/\.js:\d+/i,
|
||||
/at\s+.+\(.+:\d+:\d+\)/,
|
||||
/Unexpected token/i,
|
||||
/SQLALCHEMY|werkzeug|flask/i,
|
||||
/date_from|date_to|YYYY-MM-DD/i,
|
||||
/\bapi\/[a-z0-9_/-]+/i,
|
||||
/zootechnician|warehouse_keeper|recipient/i,
|
||||
/Content-Disposition/i,
|
||||
/latin-1/i,
|
||||
/ordinal not in range/i,
|
||||
/GET\s+\/api\//i,
|
||||
/sync-diagnostics/i,
|
||||
/\bfail\b$/i,
|
||||
/chat fail|ping fail|summary fail|diagnostics fail|requeue fail|restart fail|runtime fail|push fail/i,
|
||||
/status fail/i,
|
||||
/no response/i,
|
||||
];
|
||||
|
||||
function plainText(message) {
|
||||
if (message instanceof Error) return "";
|
||||
if (message && typeof message === "object") {
|
||||
if (Array.isArray(message.parts)) {
|
||||
return message.parts.map((p) => (p && p.text != null ? String(p.text) : "")).join("");
|
||||
}
|
||||
if (message.text != null) return String(message.text);
|
||||
}
|
||||
return message == null ? "" : String(message);
|
||||
}
|
||||
|
||||
function isTechnicalMessage(message) {
|
||||
const text = plainText(message).trim();
|
||||
if (!text) return true;
|
||||
return TECHNICAL_MESSAGE_PATTERNS.some((pattern) => pattern.test(text));
|
||||
}
|
||||
|
||||
function userFacingMessage(message, fallback) {
|
||||
const fb = (fallback && String(fallback).trim()) || DEFAULT_ERROR_MESSAGE;
|
||||
if (message instanceof Error) return fb;
|
||||
const text = plainText(message).trim();
|
||||
if (!text || isTechnicalMessage(text)) return fb;
|
||||
return text;
|
||||
}
|
||||
|
||||
function messageFromResponseBody(body, fallback) {
|
||||
if (!body || typeof body !== "object") {
|
||||
return userFacingMessage(null, fallback);
|
||||
}
|
||||
return userFacingMessage(body.message || body.error || null, fallback);
|
||||
}
|
||||
|
||||
function safe(message, fallback) {
|
||||
return userFacingMessage(message, fallback);
|
||||
}
|
||||
|
||||
global.WespUserMessages = {
|
||||
DEFAULT_ERROR_MESSAGE,
|
||||
plainText,
|
||||
isTechnicalMessage,
|
||||
userFacingMessage,
|
||||
messageFromResponseBody,
|
||||
safe,
|
||||
};
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
@@ -96,14 +96,31 @@
|
||||
const mobileMenuOverlay = document.getElementById("mobileMenuOverlay");
|
||||
const closeMenu = document.getElementById("closeMenu");
|
||||
if (!hamburgerMenu || !mobileMenuOverlay) return;
|
||||
if (mobileMenuOverlay.dataset.menuWired === "1") return;
|
||||
mobileMenuOverlay.dataset.menuWired = "1";
|
||||
|
||||
if (mobileMenuOverlay.parentElement !== document.body) {
|
||||
document.body.appendChild(mobileMenuOverlay);
|
||||
}
|
||||
|
||||
const syncMobileUserInfo = () => {
|
||||
const userInfo = document.getElementById("userInfo");
|
||||
const mobileUserInfo = document.getElementById("mobileUserInfo");
|
||||
if (userInfo && mobileUserInfo) {
|
||||
mobileUserInfo.textContent = userInfo.textContent?.trim() || "";
|
||||
}
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
syncMobileUserInfo();
|
||||
mobileMenuOverlay.classList.add("active");
|
||||
hamburgerMenu.classList.add("active");
|
||||
document.body.classList.add("mobile-menu-open");
|
||||
};
|
||||
const close = () => {
|
||||
mobileMenuOverlay.classList.remove("active");
|
||||
hamburgerMenu.classList.remove("active");
|
||||
document.body.classList.remove("mobile-menu-open");
|
||||
};
|
||||
|
||||
hamburgerMenu.addEventListener("click", open);
|
||||
@@ -111,6 +128,14 @@
|
||||
mobileMenuOverlay.addEventListener("click", (e) => {
|
||||
if (e.target === mobileMenuOverlay) close();
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && mobileMenuOverlay.classList.contains("active")) close();
|
||||
});
|
||||
document.querySelectorAll(".mobile-menu-item").forEach((item) => {
|
||||
item.addEventListener("click", () => setTimeout(close, 300));
|
||||
});
|
||||
|
||||
window.WespZootechNavCloseMobileMenu = close;
|
||||
}
|
||||
|
||||
async function fetchCanLab() {
|
||||
|
||||
@@ -15,6 +15,31 @@
|
||||
let hubEl = null;
|
||||
let planMountEl = null;
|
||||
let multiserverMountEl = null;
|
||||
let orchContextLoaded = false;
|
||||
|
||||
async function ensureOrchContext() {
|
||||
if (orchContextLoaded || global.__WESP_ORCH__?.enterpriseId) {
|
||||
orchContextLoaded = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch("/api/v1/orchestrator/context");
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
global.__WESP_ORCH__ = Object.assign(global.__WESP_ORCH__ || {}, {
|
||||
enterpriseId: data.enterpriseId || "",
|
||||
configured: !!data.configured,
|
||||
reachable: !!data.reachable,
|
||||
upstream: data.upstream || "",
|
||||
hubSiteId: data.hubSiteId || "",
|
||||
});
|
||||
}
|
||||
} catch (_err) {
|
||||
/* hub may run without orchestrator */
|
||||
} finally {
|
||||
orchContextLoaded = true;
|
||||
}
|
||||
}
|
||||
let titleEl = null;
|
||||
let readAllBtn = null;
|
||||
let backBtn = null;
|
||||
@@ -789,7 +814,7 @@
|
||||
showModal();
|
||||
}
|
||||
|
||||
function openMultiserver() {
|
||||
async function openMultiserver() {
|
||||
ensureModal();
|
||||
updateHeader("multiserver");
|
||||
hubEl.hidden = true;
|
||||
@@ -797,6 +822,7 @@
|
||||
planMountEl.hidden = true;
|
||||
multiserverMountEl.hidden = false;
|
||||
destroyDailyPlanPanel();
|
||||
await ensureOrchContext();
|
||||
const enterpriseId = global.__WESP_ORCH__?.enterpriseId || "";
|
||||
if (global.WespMultiserverPanel?.mount) {
|
||||
global.WespMultiserverPanel.mount(multiserverMountEl, enterpriseId);
|
||||
|
||||
+23
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user