884 lines
31 KiB
JavaScript
884 lines
31 KiB
JavaScript
import { mountWespLogoLoader } from "/static/js/wesp-logo-loader.js";
|
|
import { createSetupOrchestrator } from "/static/js/setup-orchestrator.js";
|
|
import { SetupTouchKeyboard } from "/static/js/setup-touch-keyboard.js";
|
|
import {
|
|
DEFAULT_PASSWORD_MIN_LEN,
|
|
validateNewUserCredentials,
|
|
} from "/static/js/wesp-user-credentials.js";
|
|
|
|
const API = {
|
|
status: "/api/setup/status",
|
|
deviceRole: "/api/setup/device-role",
|
|
network: "/api/setup/network",
|
|
sync: "/api/setup/sync",
|
|
syncProgress: "/api/setup/sync/progress",
|
|
users: "/api/setup/users",
|
|
hardware: "/api/setup/hardware/check",
|
|
kioskPrepare: "/api/setup/kiosk/prepare",
|
|
kioskSetupAvailable: "/api/kiosk/setup-available",
|
|
complete: "/api/setup/complete",
|
|
kioskAccessLink: "/api/kiosk/access-link",
|
|
kioskStatus: "/api/kiosk/status",
|
|
};
|
|
|
|
const THEME_STORAGE_KEY = "theme";
|
|
const STEP_LABELS = {
|
|
theme: "Тема",
|
|
welcome: "Начало",
|
|
device: "Устройство",
|
|
network: "Сеть",
|
|
sync: "Сервер",
|
|
users: "Пользователи",
|
|
hardware: "Весы",
|
|
kiosk: "Терминал",
|
|
bootstrap: "Синхронизация",
|
|
done: "Готово",
|
|
};
|
|
function passwordMinLen(statusData) {
|
|
const n = Number(statusData?.user_rules?.password_min_len);
|
|
return Number.isFinite(n) && n > 0 ? n : DEFAULT_PASSWORD_MIN_LEN;
|
|
}
|
|
|
|
async function fetchJson(url, options) {
|
|
const response = await fetch(url, options);
|
|
const data = await response.json().catch(() => ({}));
|
|
if (!response.ok) {
|
|
const msg = globalThis.WespUserMessages?.messageFromResponseBody?.(
|
|
data,
|
|
"Не удалось выполнить запрос"
|
|
) || "Не удалось выполнить запрос";
|
|
throw new Error(msg);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
function setupUserError(message, fallback) {
|
|
return globalThis.WespUserMessages?.userFacingMessage?.(message, fallback) || fallback || "Не удалось выполнить операцию.";
|
|
}
|
|
|
|
async function canIssueKioskAccessLink() {
|
|
try {
|
|
const data = await fetchJson(API.kioskSetupAvailable);
|
|
return !!data.can_issue_access_link;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function el(tag, className, text) {
|
|
const node = document.createElement(tag);
|
|
if (className) node.className = className;
|
|
if (text != null) node.textContent = text;
|
|
return node;
|
|
}
|
|
|
|
function field(label, input) {
|
|
const wrap = el("div", "wesp-setup__field");
|
|
wrap.appendChild(el("label", "wesp-setup__label", label));
|
|
wrap.appendChild(input);
|
|
return wrap;
|
|
}
|
|
|
|
function fieldWithSuffix(label, input, suffixText) {
|
|
const wrap = el("div", "wesp-setup__field");
|
|
wrap.appendChild(el("label", "wesp-setup__label", label));
|
|
const group = el("div", "wesp-setup__input-group");
|
|
group.appendChild(input);
|
|
group.appendChild(el("span", "wesp-setup__input-suffix", suffixText));
|
|
wrap.appendChild(group);
|
|
return wrap;
|
|
}
|
|
|
|
function normalizeHostnameLabel(value) {
|
|
return String(value || "").trim().toLowerCase().replace(/\.local$/i, "");
|
|
}
|
|
|
|
function formatSyncServerDisplay(serverUrl) {
|
|
const raw = String(serverUrl || "").trim().replace(/\/+$/, "");
|
|
if (raw) return raw;
|
|
return "http://komton_srv_1.local";
|
|
}
|
|
|
|
function buildSyncServerUrl(raw) {
|
|
const value = String(raw || "").trim().replace(/\/+$/, "");
|
|
if (!value) return "";
|
|
if (/^https?:\/\//i.test(value)) return value;
|
|
if (/^\d+\.\d+\.\d+\.\d+(:\d+)?$/i.test(value)) return `http://${value}`;
|
|
const host = normalizeHostnameLabel(value);
|
|
return host ? `http://${host}.local` : "";
|
|
}
|
|
|
|
function readSetupTheme() {
|
|
try {
|
|
const t = (localStorage.getItem(THEME_STORAGE_KEY) || "").trim().toLowerCase();
|
|
return t === "light" ? "light" : "dark";
|
|
} catch {
|
|
return "dark";
|
|
}
|
|
}
|
|
|
|
function applySetupTheme(theme) {
|
|
const next = theme === "dark" ? "dark" : "light";
|
|
document.documentElement.setAttribute("data-theme", next);
|
|
try {
|
|
localStorage.setItem(THEME_STORAGE_KEY, next);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
function stepIndicatorLabel(step) {
|
|
return STEP_LABELS[step] || step || "";
|
|
}
|
|
|
|
function textInput(id, placeholder, type = "text") {
|
|
const input = document.createElement("input");
|
|
input.className = "wesp-setup__input";
|
|
input.id = id;
|
|
input.type = type;
|
|
input.placeholder = placeholder;
|
|
return input;
|
|
}
|
|
|
|
function postSetupLandingPath(statusData) {
|
|
const role = statusData?.setup?.device_role;
|
|
const scaleHw = Boolean(statusData?.hardware?.scale_hardware_platform);
|
|
if (role === "client" && scaleHw) return "/scales";
|
|
return "/login";
|
|
}
|
|
|
|
export async function mountSetupWizard(root) {
|
|
const orchestrator = createSetupOrchestrator();
|
|
let statusData = null;
|
|
let bootstrapTimer = 0;
|
|
let activeTouchKeyboard = null;
|
|
let introStarted = false;
|
|
|
|
const intro = el("div", "wesp-setup__intro");
|
|
intro.setAttribute("aria-hidden", "false");
|
|
root.appendChild(intro);
|
|
|
|
const panel = el("div", "wesp-setup__panel wesp-setup__panel--hidden");
|
|
panel.setAttribute("role", "dialog");
|
|
panel.setAttribute("aria-live", "polite");
|
|
root.appendChild(panel);
|
|
|
|
const statusPromise = loadStatusEarly();
|
|
|
|
async function loadStatusEarly() {
|
|
statusData = await fetchJson(API.status);
|
|
if (statusData.setup?.setup_completed) {
|
|
window.location.href = postSetupLandingPath(statusData);
|
|
return statusData;
|
|
}
|
|
if (statusData.setup?.device_role === "client") {
|
|
orchestrator.setDeviceRole("client");
|
|
} else {
|
|
orchestrator.setDeviceRole("server");
|
|
}
|
|
return statusData;
|
|
}
|
|
|
|
async function playIntroAnimation() {
|
|
if (introStarted) return;
|
|
introStarted = true;
|
|
await mountWespLogoLoader(intro, {
|
|
loop: false,
|
|
embedded: true,
|
|
transparentBackground: true,
|
|
knockoutBackground: true,
|
|
darkTheme: readSetupTheme() === "dark",
|
|
hideChrome: true,
|
|
onComplete: () => revealWelcome(),
|
|
});
|
|
}
|
|
|
|
async function revealWelcome() {
|
|
intro.classList.add("wesp-setup__intro--done");
|
|
intro.setAttribute("aria-hidden", "true");
|
|
await statusPromise;
|
|
if (statusData?.setup?.setup_completed) return;
|
|
panel.classList.remove("wesp-setup__panel--hidden");
|
|
renderTheme();
|
|
window.setTimeout(() => {
|
|
if (intro.isConnected) intro.remove();
|
|
}, 480);
|
|
}
|
|
|
|
playIntroAnimation();
|
|
|
|
function setPanelBusy(busy, label = "Сохранение…") {
|
|
let overlay = panel.querySelector(".wesp-setup__busy");
|
|
if (busy) {
|
|
if (!overlay) {
|
|
overlay = el("div", "wesp-setup__busy");
|
|
overlay.appendChild(el("span", "wesp-setup__spinner"));
|
|
overlay.appendChild(el("span", "wesp-setup__busy-label", label));
|
|
panel.appendChild(overlay);
|
|
} else {
|
|
overlay.querySelector(".wesp-setup__busy-label").textContent = label;
|
|
overlay.hidden = false;
|
|
}
|
|
panel.classList.add("wesp-setup__panel--busy");
|
|
panel.setAttribute("aria-busy", "true");
|
|
return;
|
|
}
|
|
overlay?.remove();
|
|
panel.classList.remove("wesp-setup__panel--busy");
|
|
panel.removeAttribute("aria-busy");
|
|
}
|
|
|
|
async function withStepLoading(label, fn) {
|
|
setPanelBusy(true, label);
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
setPanelBusy(false);
|
|
}
|
|
}
|
|
|
|
function renderShell(title, lead) {
|
|
activeTouchKeyboard?.destroy();
|
|
activeTouchKeyboard = null;
|
|
panel.replaceChildren();
|
|
panel.classList.remove("wesp-setup__panel--busy");
|
|
panel.removeAttribute("aria-busy");
|
|
|
|
const header = el("div", "wesp-setup__header");
|
|
header.appendChild(
|
|
el("div", "wesp-setup__step-indicator", `Шаг · ${stepIndicatorLabel(orchestrator.currentStep)}`),
|
|
);
|
|
header.appendChild(el("h1", "wesp-setup__title", title));
|
|
if (lead) header.appendChild(el("p", "wesp-setup__lead", lead));
|
|
panel.appendChild(header);
|
|
|
|
const content = el("div", "wesp-setup__content");
|
|
panel.appendChild(content);
|
|
return content;
|
|
}
|
|
|
|
function populateActionRow(row, ...buttons) {
|
|
row.replaceChildren();
|
|
let primaryIndex = -1;
|
|
buttons.forEach((b, i) => {
|
|
if (b.classList.contains("wesp-setup__btn--primary")) primaryIndex = i;
|
|
});
|
|
buttons.forEach((b, i) => {
|
|
if (!b.classList.contains("wesp-setup__btn--primary")) {
|
|
b.classList.add("wesp-setup__btn--ghost");
|
|
}
|
|
if (i === primaryIndex && primaryIndex > 0) {
|
|
row.appendChild(el("div", "wesp-setup__actions-fill"));
|
|
}
|
|
row.appendChild(b);
|
|
});
|
|
}
|
|
|
|
function actions(...buttons) {
|
|
const footer = el("div", "wesp-setup__footer");
|
|
const row = el("div", "wesp-setup__actions");
|
|
populateActionRow(row, ...buttons);
|
|
footer.appendChild(row);
|
|
panel.appendChild(footer);
|
|
return row;
|
|
}
|
|
|
|
function msg(text, kind, target) {
|
|
const m = el("p", `wesp-setup__msg${kind ? ` wesp-setup__msg--${kind}` : ""}`, text || "");
|
|
(target || panel.querySelector(".wesp-setup__content") || panel).appendChild(m);
|
|
return m;
|
|
}
|
|
|
|
function btn(label, className, onClick) {
|
|
const b = el("button", `wesp-setup__btn${className ? ` ${className}` : ""}`, label);
|
|
b.type = "button";
|
|
b.addEventListener("click", onClick);
|
|
return b;
|
|
}
|
|
|
|
async function loadStatus() {
|
|
statusData = await fetchJson(API.status);
|
|
if (statusData.setup?.setup_completed) {
|
|
window.location.href = postSetupLandingPath(statusData);
|
|
return statusData;
|
|
}
|
|
if (statusData.setup?.device_role === "client") {
|
|
orchestrator.setDeviceRole("client");
|
|
} else {
|
|
orchestrator.setDeviceRole("server");
|
|
}
|
|
return statusData;
|
|
}
|
|
|
|
function shouldShowBootstrap() {
|
|
return !!statusData?.setup?.sync_server_connected;
|
|
}
|
|
|
|
function goAfterKiosk() {
|
|
if (shouldShowBootstrap()) renderBootstrap();
|
|
else goComplete();
|
|
}
|
|
|
|
async function goComplete() {
|
|
orchestrator.setStep("done");
|
|
const data = await withStepLoading("Завершение…", () =>
|
|
fetchJson(API.complete, { method: "POST" }),
|
|
);
|
|
const fallbackRedirect = postSetupLandingPath(statusData);
|
|
const redirect = data.redirect || fallbackRedirect;
|
|
const opensScales = redirect === "/scales";
|
|
renderShell(
|
|
"Готово",
|
|
opensScales
|
|
? "Система настроена. Сейчас откроется экран весов."
|
|
: "Система настроена. Сейчас откроется страница входа.",
|
|
);
|
|
msg(data.message, "ok");
|
|
actions(btn("Открыть", "wesp-setup__btn--primary", () => {
|
|
window.location.href = redirect;
|
|
}));
|
|
window.setTimeout(() => {
|
|
window.location.href = redirect;
|
|
}, 2200);
|
|
}
|
|
|
|
function renderTheme() {
|
|
orchestrator.setStep("theme");
|
|
const content = renderShell(
|
|
"Оформление",
|
|
"Выберите тему. Её можно сменить позже в меню терминала.",
|
|
);
|
|
let selected = readSetupTheme();
|
|
applySetupTheme(selected);
|
|
|
|
const grid = el("div", "wesp-setup__role-grid wesp-setup__role-grid--cards wesp-setup__theme-grid");
|
|
const options = [
|
|
["light", "Светлая", "Светлый фон и контрастные элементы."],
|
|
["dark", "Тёмная", "Удобнее при слабом освещении."],
|
|
];
|
|
const buttons = [];
|
|
options.forEach(([value, title, hint]) => {
|
|
const b = el("button", `wesp-setup__role-btn wesp-setup__role-card wesp-setup__theme-card wesp-setup__theme-card--${value}`, "");
|
|
b.type = "button";
|
|
b.innerHTML = `<strong>${title}</strong><span>${hint}</span>`;
|
|
if (selected === value) b.classList.add("is-selected");
|
|
b.addEventListener("click", () => {
|
|
selected = value;
|
|
applySetupTheme(selected);
|
|
buttons.forEach((x) => x.classList.toggle("is-selected", x === b));
|
|
});
|
|
buttons.push(b);
|
|
grid.appendChild(b);
|
|
});
|
|
content.appendChild(grid);
|
|
|
|
actions(btn("Далее", "wesp-setup__btn--primary", () => renderWelcome()));
|
|
}
|
|
|
|
function renderWelcome() {
|
|
orchestrator.setStep("welcome");
|
|
renderShell(
|
|
"Добро пожаловать",
|
|
"Мастер поможет выбрать роль устройства и базовые параметры.",
|
|
);
|
|
const health = statusData?.health;
|
|
if (health?.ok) {
|
|
msg("Система готова к настройке.", "ok");
|
|
} else {
|
|
msg("Проверка health… при ошибках миграций обратитесь к установщику.", "error");
|
|
}
|
|
actions(btn("Начать", "wesp-setup__btn--primary", () => renderDevice()));
|
|
}
|
|
|
|
function renderDevice() {
|
|
orchestrator.setStep("device");
|
|
const content = renderShell("Тип устройства", "Где будет работать это устройство?");
|
|
const grid = el("div", "wesp-setup__role-grid wesp-setup__role-grid--cards");
|
|
const roles = [
|
|
["server", "Главный сервер"],
|
|
["client", "Весовой терминал"],
|
|
];
|
|
let selected = orchestrator.deviceRole === "client" ? "client" : "server";
|
|
const buttons = [];
|
|
roles.forEach(([value, title]) => {
|
|
const b = el("button", "wesp-setup__role-btn wesp-setup__role-card", "");
|
|
b.type = "button";
|
|
b.innerHTML = `<strong>${title}</strong>`;
|
|
if (selected === value) b.classList.add("is-selected");
|
|
b.addEventListener("click", () => {
|
|
selected = value;
|
|
buttons.forEach((x) => x.classList.toggle("is-selected", x === b));
|
|
});
|
|
buttons.push(b);
|
|
grid.appendChild(b);
|
|
});
|
|
content.appendChild(grid);
|
|
const statusEl = msg("");
|
|
actions(
|
|
btn("Назад", "", () => renderWelcome()),
|
|
btn("Далее", "wesp-setup__btn--primary", async () => {
|
|
try {
|
|
const data = await withStepLoading("Сохранение…", () =>
|
|
fetchJson(API.deviceRole, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ device_role: selected }),
|
|
}),
|
|
);
|
|
if (data?.connection && statusData) {
|
|
statusData.connection = data.connection;
|
|
}
|
|
orchestrator.setDeviceRole(selected);
|
|
if (selected === "server") renderNetwork();
|
|
else renderSync();
|
|
} catch (e) {
|
|
statusEl.textContent = setupUserError(e.message, "Не удалось выполнить шаг настройки.");
|
|
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
function renderNetwork() {
|
|
orchestrator.setStep("network");
|
|
const content = renderShell(
|
|
"Сеть",
|
|
"Как этот сервер будет называться в вашей Wi‑Fi сети.",
|
|
);
|
|
const net = statusData?.network || {};
|
|
const hostDefault = normalizeHostnameLabel(
|
|
net.local_hostname || net.default_local_hostname || "komton_srv_1",
|
|
);
|
|
const hostLocked = !!net.local_hostname_locked_by_env;
|
|
const hostInput = textInput("setupHost", hostDefault);
|
|
hostInput.placeholder = "komton_srv_1";
|
|
hostInput.disabled = hostLocked;
|
|
hostInput.autocomplete = "off";
|
|
hostInput.spellcheck = false;
|
|
hostInput.addEventListener("input", () => {
|
|
const cleaned = normalizeHostnameLabel(hostInput.value);
|
|
if (hostInput.value !== cleaned) hostInput.value = cleaned;
|
|
});
|
|
|
|
content.appendChild(fieldWithSuffix("Имя сервера", hostInput, ".local"));
|
|
content.appendChild(
|
|
el(
|
|
"p",
|
|
"wesp-setup__hint",
|
|
"Латиница и цифры, без пробелов. Суффикс .local добавится автоматически.",
|
|
),
|
|
);
|
|
if (net.detected?.lan_ip) {
|
|
content.appendChild(el("p", "wesp-setup__hint", `IP в сети: ${net.detected.lan_ip}`));
|
|
}
|
|
const statusEl = msg("");
|
|
actions(
|
|
btn("Назад", "", () => renderDevice()),
|
|
btn("Далее", "wesp-setup__btn--primary", async () => {
|
|
try {
|
|
const body = { local_hostname: normalizeHostnameLabel(hostInput.value) };
|
|
if (!net.mdns_enabled_locked_by_env) {
|
|
body.mdns_enabled = true;
|
|
}
|
|
await withStepLoading("Сохранение…", async () => {
|
|
await fetchJson(API.network, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
await loadStatus();
|
|
});
|
|
renderUsers();
|
|
} catch (e) {
|
|
statusEl.textContent = setupUserError(e.message, "Не удалось выполнить шаг настройки.");
|
|
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
function renderUsers() {
|
|
orchestrator.setStep("users");
|
|
const content = renderShell(
|
|
"Учётные записи",
|
|
"Создайте учётную запись зоотехника для работы с рецептами.",
|
|
);
|
|
const zootechUsers = statusData?.zootech_users || [];
|
|
if (zootechUsers.length) {
|
|
content.appendChild(
|
|
el(
|
|
"p",
|
|
"wesp-setup__info",
|
|
`Уже есть: ${zootechUsers.join(", ")}. Тот же логин — обновит пароль, новый — добавит ещё одного зоотехника.`,
|
|
),
|
|
);
|
|
}
|
|
const zootechLogin = textInput("zootechLogin", zootechUsers[0] || "zootech");
|
|
const zootechPass = textInput("zootechPass", "Пароль", "password");
|
|
const zootechConfirm = textInput("zootechConfirm", "Повтор пароля", "password");
|
|
content.appendChild(field("Логин", zootechLogin));
|
|
content.appendChild(field("Пароль", zootechPass));
|
|
content.appendChild(field("Подтверждение", zootechConfirm));
|
|
|
|
const statusEl = msg("");
|
|
actions(
|
|
btn("Назад", "", () => renderNetwork()),
|
|
btn("Далее", "wesp-setup__btn--primary", async () => {
|
|
const validationError = validateNewUserCredentials({
|
|
login: zootechLogin.value,
|
|
password: zootechPass.value,
|
|
confirmPassword: zootechConfirm.value,
|
|
passwordMinLen: passwordMinLen(statusData),
|
|
label: "Зоотехник",
|
|
});
|
|
if (validationError) {
|
|
statusEl.textContent = validationError;
|
|
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
|
|
return;
|
|
}
|
|
try {
|
|
const data = await withStepLoading("Проверка…", () =>
|
|
fetchJson(API.users, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
zootech: {
|
|
login: zootechLogin.value.trim(),
|
|
password: zootechPass.value,
|
|
confirm: zootechConfirm.value,
|
|
},
|
|
}),
|
|
}),
|
|
);
|
|
await loadStatus();
|
|
statusEl.textContent = data.message || "Сохранено.";
|
|
statusEl.className = "wesp-setup__msg wesp-setup__msg--ok";
|
|
window.setTimeout(() => goComplete(), 350);
|
|
} catch (e) {
|
|
statusEl.textContent = setupUserError(e.message, "Не удалось выполнить шаг настройки.");
|
|
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
function renderHardware() {
|
|
orchestrator.setStep("hardware");
|
|
const content = renderShell(
|
|
"Весы",
|
|
"Проверьте подключение весов или выполните калибровку.",
|
|
);
|
|
const platform = statusData?.hardware || {};
|
|
if (platform.scale_hardware_platform) {
|
|
content.appendChild(
|
|
el(
|
|
"p",
|
|
"wesp-setup__hint",
|
|
"Платформа ARM — можно подключить весы HX711 к Raspberry Pi.",
|
|
),
|
|
);
|
|
} else {
|
|
content.appendChild(
|
|
el(
|
|
"p",
|
|
"wesp-setup__info wesp-setup__info--warn",
|
|
platform.message || "Весы не найдены",
|
|
),
|
|
);
|
|
}
|
|
const statusEl = msg("");
|
|
actions(
|
|
btn("Назад", "", () => renderSync()),
|
|
btn("Проверить весы", "", async () => {
|
|
if (!platform.scale_hardware_platform) {
|
|
statusEl.textContent = platform.message || "Весы не найдены";
|
|
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
|
|
return;
|
|
}
|
|
try {
|
|
const data = await withStepLoading("Проверка весов…", () =>
|
|
fetchJson(API.hardware, { method: "POST" }),
|
|
);
|
|
statusEl.textContent = data.ok
|
|
? `OK · вес ${data.hardware?.current_weight_kg ?? "—"} кг`
|
|
: "HX711 не отвечает — проверьте подключение или включите симуляцию в админке.";
|
|
statusEl.className = `wesp-setup__msg ${data.ok ? "wesp-setup__msg--ok" : "wesp-setup__msg--error"}`;
|
|
} catch (e) {
|
|
statusEl.textContent = setupUserError(e.message, "Не удалось выполнить шаг настройки.");
|
|
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
|
|
}
|
|
}),
|
|
btn("Калибровка", "", () => {
|
|
if (window.WespKioskCalibration?.openModal) {
|
|
window.WespKioskCalibration.openModal({ zIndex: 10050, theme: readSetupTheme() });
|
|
return;
|
|
}
|
|
window.open("/calibration?embed=1", "_blank", "noopener,noreferrer");
|
|
}),
|
|
btn("Далее", "wesp-setup__btn--primary", () => renderKiosk()),
|
|
);
|
|
}
|
|
|
|
async function renderKiosk() {
|
|
orchestrator.setStep("kiosk");
|
|
|
|
const localSetup = await canIssueKioskAccessLink();
|
|
if (!localSetup) {
|
|
const content = renderShell(
|
|
"Настройка терминала",
|
|
"Этот шаг выполняется на самом терминале.",
|
|
);
|
|
content.appendChild(el("p", "wesp-setup__info", "Настройка производится на терминале."));
|
|
actions(
|
|
btn("Назад", "", () => renderHardware()),
|
|
btn("Далее", "wesp-setup__btn--primary", () => goAfterKiosk()),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const content = renderShell(
|
|
"Настройка терминала",
|
|
"Привязка планшета тракториста по QR-коду.",
|
|
);
|
|
content.appendChild(
|
|
el(
|
|
"p",
|
|
"wesp-setup__info",
|
|
"Дополнительные планшеты можно привязать позже через меню ☰ → Привязать терминал.",
|
|
),
|
|
);
|
|
const qrWrap = el("div", "wesp-setup__qr-wrap");
|
|
const qr = el("img", "wesp-setup__qr");
|
|
qr.alt = "QR Start URL";
|
|
const urlEl = el("p", "wesp-setup__url");
|
|
qrWrap.appendChild(qr);
|
|
content.appendChild(qrWrap);
|
|
content.appendChild(urlEl);
|
|
const statusEl = msg("");
|
|
|
|
try {
|
|
await withStepLoading("Подготовка QR…", async () => {
|
|
await fetchJson(API.kioskPrepare, { method: "POST" });
|
|
const data = await fetchJson(API.kioskAccessLink);
|
|
qr.src = data.qr_image_url || "";
|
|
urlEl.textContent = data.start_url || "";
|
|
});
|
|
statusEl.textContent = "Вставьте ссылку в Fully Kiosk → Start URL.";
|
|
statusEl.className = "wesp-setup__msg wesp-setup__msg--ok";
|
|
} catch (e) {
|
|
statusEl.textContent = setupUserError(e.message, "Не удалось выполнить шаг настройки.");
|
|
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
|
|
}
|
|
|
|
actions(
|
|
btn("Назад", "", () => renderHardware()),
|
|
btn("Проверить статус", "", async () => {
|
|
try {
|
|
const st = await withStepLoading("Проверка…", () => fetchJson(API.kioskStatus));
|
|
statusEl.textContent = st.paired ? "Терминал привязан." : "Ожидание привязки…";
|
|
statusEl.className = `wesp-setup__msg ${st.paired ? "wesp-setup__msg--ok" : ""}`;
|
|
} catch (e) {
|
|
statusEl.textContent = setupUserError(e.message, "Не удалось выполнить шаг настройки.");
|
|
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
|
|
}
|
|
}),
|
|
btn("Пропустить", "", () => goAfterKiosk()),
|
|
btn("Далее", "wesp-setup__btn--primary", () => goAfterKiosk()),
|
|
);
|
|
}
|
|
|
|
function renderSync() {
|
|
orchestrator.setStep("sync");
|
|
const content = renderShell(
|
|
"Подключение к серверу",
|
|
"Укажите адрес главного сервера и имя этого терминала.",
|
|
);
|
|
const conn = statusData?.connection || {};
|
|
const serverLocked = !!conn.server_url_locked_by_env;
|
|
const serverInput = textInput(
|
|
"syncServer",
|
|
serverLocked ? conn.server_url || conn.env_server_url || "" : formatSyncServerDisplay(conn.server_url),
|
|
);
|
|
serverInput.placeholder = "komton_srv_1.local";
|
|
serverInput.disabled = serverLocked;
|
|
content.appendChild(field("Адрес сервера", serverInput));
|
|
if (serverLocked) {
|
|
content.appendChild(
|
|
el(
|
|
"p",
|
|
"wesp-setup__hint",
|
|
"Адрес задан в настройках системы (WESP_SYNC_SERVER_URL).",
|
|
),
|
|
);
|
|
} else {
|
|
content.appendChild(
|
|
el(
|
|
"p",
|
|
"wesp-setup__hint",
|
|
"Имя сервера (komton_srv_1.local) или IP, например http://192.168.0.10.",
|
|
),
|
|
);
|
|
}
|
|
|
|
const nameDefault =
|
|
conn.client_name ||
|
|
normalizeHostnameLabel(statusData?.network?.local_hostname || "") ||
|
|
statusData?.network?.detected?.os_hostname ||
|
|
"vesy_1";
|
|
const nameInput = textInput("syncName", nameDefault);
|
|
nameInput.placeholder = "vesy_1";
|
|
content.appendChild(field("Название терминала", nameInput));
|
|
|
|
const statusEl = msg("");
|
|
let actionsRow = null;
|
|
|
|
async function submitSync(offline) {
|
|
const serverUrl = buildSyncServerUrl(serverInput.value);
|
|
if (!serverUrl && !serverLocked) {
|
|
statusEl.textContent = "Укажите адрес сервера.";
|
|
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
|
|
return;
|
|
}
|
|
const body = {
|
|
client_name: nameInput.value.trim(),
|
|
offline: !!offline,
|
|
};
|
|
if (!serverLocked) {
|
|
body.server_url = serverUrl;
|
|
}
|
|
await withStepLoading(offline ? "Сохранение…" : "Подключение…", () =>
|
|
fetchJson(API.sync, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
}),
|
|
);
|
|
await loadStatus();
|
|
renderHardware();
|
|
}
|
|
|
|
function showSyncFailureChoices(errorMessage) {
|
|
statusEl.textContent = setupUserError(errorMessage, "Не удалось подключиться к серверу.");
|
|
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
|
|
if (!actionsRow) return;
|
|
populateActionRow(
|
|
actionsRow,
|
|
btn("Исправить адрес", "", () => {
|
|
serverInput.focus();
|
|
statusEl.textContent = "";
|
|
statusEl.className = "wesp-setup__msg";
|
|
populateActionRow(
|
|
actionsRow,
|
|
btn("Назад", "", () => renderDevice()),
|
|
btn("Далее", "wesp-setup__btn--primary", onNext),
|
|
);
|
|
}),
|
|
btn("Повторить", "wesp-setup__btn--primary", onNext),
|
|
btn("Продолжить без сервера", "", async () => {
|
|
try {
|
|
await submitSync(true);
|
|
} catch (e) {
|
|
statusEl.textContent = setupUserError(e.message, "Не удалось выполнить шаг настройки.");
|
|
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
async function onNext() {
|
|
try {
|
|
await submitSync(false);
|
|
} catch (e) {
|
|
showSyncFailureChoices(e.message);
|
|
}
|
|
}
|
|
|
|
actionsRow = actions(
|
|
btn("Назад", "", () => renderDevice()),
|
|
btn("Далее", "wesp-setup__btn--primary", onNext),
|
|
);
|
|
|
|
activeTouchKeyboard = new SetupTouchKeyboard(panel);
|
|
if (!serverInput.disabled) {
|
|
activeTouchKeyboard.attach(serverInput, { layout: "url", maxLength: 256 });
|
|
}
|
|
activeTouchKeyboard.attach(nameInput, { layout: "hostname", maxLength: 200 });
|
|
nameInput.addEventListener("input", () => {
|
|
const cleaned = normalizeHostnameLabel(nameInput.value);
|
|
if (nameInput.value !== cleaned) nameInput.value = cleaned;
|
|
});
|
|
window.setTimeout(() => {
|
|
if (!serverInput.disabled) serverInput.focus();
|
|
else nameInput.focus();
|
|
}, 80);
|
|
}
|
|
|
|
function renderBootstrap() {
|
|
if (!shouldShowBootstrap()) {
|
|
goComplete();
|
|
return;
|
|
}
|
|
orchestrator.setStep("bootstrap");
|
|
const content = renderShell(
|
|
"Первая синхронизация",
|
|
"Дождитесь загрузки данных с сервера.",
|
|
);
|
|
const bar = el("div", "wesp-setup__progress-bar");
|
|
const fill = el("div", "wesp-setup__progress-fill");
|
|
bar.appendChild(fill);
|
|
content.appendChild(bar);
|
|
const statusEl = msg("Ожидание синхронизации…");
|
|
|
|
function stopPoll() {
|
|
if (bootstrapTimer) window.clearInterval(bootstrapTimer);
|
|
bootstrapTimer = 0;
|
|
}
|
|
|
|
async function poll() {
|
|
try {
|
|
const data = await fetchJson(API.syncProgress);
|
|
const p = data.initial_sync_progress || {};
|
|
const percent = p.percent != null ? Number(p.percent) : data.first_bootstrap_done ? 100 : 10;
|
|
fill.style.width = `${Math.min(100, percent)}%`;
|
|
orchestrator.setBootstrapProgress(percent / 100);
|
|
if (data.first_bootstrap_done) {
|
|
stopPoll();
|
|
statusEl.textContent = "Синхронизация завершена.";
|
|
statusEl.className = "wesp-setup__msg wesp-setup__msg--ok";
|
|
} else if (p.active) {
|
|
statusEl.textContent = `Синхронизация… ${Math.round(percent)}%`;
|
|
}
|
|
} catch (e) {
|
|
statusEl.textContent = setupUserError(e.message, "Не удалось выполнить шаг настройки.");
|
|
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
|
|
}
|
|
}
|
|
|
|
poll();
|
|
bootstrapTimer = window.setInterval(poll, 2000);
|
|
|
|
actions(
|
|
btn("Назад", "", () => {
|
|
stopPoll();
|
|
renderKiosk();
|
|
}),
|
|
btn("Далее", "wesp-setup__btn--primary", () => {
|
|
stopPoll();
|
|
goComplete();
|
|
}),
|
|
);
|
|
}
|
|
|
|
await statusPromise;
|
|
}
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
const root = document.getElementById("wespSetupRoot");
|
|
if (root) mountSetupWizard(root);
|
|
});
|
|
} else {
|
|
const root = document.getElementById("wespSetupRoot");
|
|
if (root) mountSetupWizard(root);
|
|
}
|