я косяк, поправил веб. Капитан, работайте!

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
влад
2026-07-16 11:06:47 +03:00
co-authored by Cursor
parent 148d645760
commit 2c294424ef
85 changed files with 17410 additions and 110 deletions
@@ -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 };
})();
+147
View File
@@ -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 };
})();
+271
View File
@@ -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);
+169
View File
@@ -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 };
})();
+285
View File
@@ -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 };
})();
+153
View File
@@ -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,
+100 -24
View File
@@ -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 0100 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 }));
}
}
+339
View File
@@ -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);
+869
View File
@@ -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?.();
}
+135
View File
@@ -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);
})();
+143
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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);
File diff suppressed because one or more lines are too long