From 71f83a27dd3a5abfc3ab29853d3e6b3f7e7be0a2 Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Sat, 10 Oct 2026 12:42:44 +0300 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA=D0=B0:=20?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D0=B0=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D0=B5=D0=BB=D0=B0=D0=BD=D0=B0=20=E2=80=94=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BD=D1=8F=D1=82=D0=BD=D0=B5=D0=B5=20=D0=B8=20?= =?UTF-8?q?=D0=B2=20=D1=81=D1=82=D0=B8=D0=BB=D0=B5=20=D1=81=D0=B0=D0=B9?= =?UTF-8?q?=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - шапка как на сайте (логотип факультета, «на сайт», переключатель светлой/тёмной темы — тема общая с сайтом и не мигает при загрузке); - крупная шапка состояния: «Работает / Ожу данные / Ошибка загрузки», сколько обновлено назад, число групп и текущий интервал; рядом главная кнопка «Обновить сейчас»; - вместо россыпи чипов — карточки: «Ссылка на расписание» (с кнопкой «открыть таблицу»), «Обновление» (интервал + факты: изменился ли файл, дата, отпечаток), «Входы в админку» (последняя попытка, последний успешный вход, остаток попыток или блокировка), «Данные расписания»; - предупреждения (ошибка загрузки, невозможность сохранить настройки) вынесены отдельной плашкой под шапкой, а не спрятаны среди полей; - верстка адаптивная: сетка карточек, на телефоне одна колонка и подписи под значениями; формы с подписями и подсказками, кнопки с ясной иерархией; - уведомления и вся логика прежние; тесты 160. --- src/frontend/admin.html | 417 ++++++++++++++++++++++++++-------------- 1 file changed, 272 insertions(+), 145 deletions(-) diff --git a/src/frontend/admin.html b/src/frontend/admin.html index 3aaca7b..bfdc8b6 100644 --- a/src/frontend/admin.html +++ b/src/frontend/admin.html @@ -7,46 +7,113 @@ Админка расписания + -
-
- -
Админка расписания
-
-
Управление источником данных и обновлением. Страница доступна только по секретной ссылке.
-
+
@@ -198,71 +302,87 @@ return isNaN(d) ? String(value) : d.toLocaleString("ru-RU", { dateStyle: "short", timeStyle: "short" }); } - function chip(key, value, cls) { - const div = document.createElement("div"); - div.className = "admin-chip"; - div.innerHTML = `
${key}
`; - const v = div.querySelector(".v"); - v.textContent = value; - if (cls) v.classList.add(cls); - return div; + /** «5 минут назад» для свежести данных. */ + function ago(value) { + if (!value) return ""; + const d = new Date(value); + if (isNaN(d)) return ""; + const sec = Math.max(0, Math.round((Date.now() - d.getTime()) / 1000)); + if (sec < 60) return "только что"; + const min = Math.round(sec / 60); + if (min < 60) return `${min} мин назад`; + const hours = Math.round(min / 60); + if (hours < 24) return `${hours} ч назад`; + return `${Math.round(hours / 24)} дн назад`; } function loginText(info) { if (!info) return "нет данных"; - // у успешного входа поля ok нет — там итог и так известен const status = info.ok === undefined ? "" : ` · ${info.ok ? "успешно" : "неудачно"}`; return `${fmt(info.at)} · ${info.ip}${status}`; } - function renderState(state) { - const chips = $("status-chips"); - chips.innerHTML = ""; - chips.appendChild(chip("Интервал обновления", `${state.interval_minutes} мин`)); - chips.appendChild(chip("Обновлено", fmt(state.last_updated))); - chips.appendChild(chip( - "Файл при последнем обновлении", - state.changed ? "изменился" : "без изменений", - state.changed ? "good" : null, - )); - chips.appendChild(chip("Дата файла на Диске", fmt(state.table_modified))); - chips.appendChild(chip("Групп в расписании", String(state.groups))); - if (state.content_hash) chips.appendChild(chip("sha256 файла", state.content_hash)); - chips.appendChild(chip("Текущая ссылка", state.link || "не задана")); - if (state.settings_file) chips.appendChild(chip("Файл настроек", state.settings_file)); + function setText(id, value, cls) { + const el = $(id); + if (!el) return; + el.textContent = value; + el.classList.remove("good", "bad"); + if (cls) el.classList.add(cls); + } - const auth = $("auth-chips"); - auth.innerHTML = ""; + function renderState(state) { + // крупная строка состояния + const hero = $("hero"); + const hasError = Boolean(state.error); + hero.classList.toggle("is-error", hasError); + hero.classList.toggle("is-ok", !hasError && state.has_data); + $("hero-title").textContent = hasError + ? "Ошибка загрузки расписания" + : (state.has_data ? "Работает" : "Жду данные"); + const parts = []; + if (state.last_updated) parts.push(`обновлено ${fmt(state.last_updated)} (${ago(state.last_updated)})`); + parts.push(`групп: ${state.groups}`); + parts.push(`интервал ${state.interval_minutes} мин`); + $("hero-sub").textContent = parts.join(" · "); + + const alert = $("hero-alert"); + const warnings = []; + if (state.error) warnings.push("Ошибка загрузки: " + state.error); + if (state.settings_save_error) { + warnings.push("Настройки не сохраняются на диск: " + state.settings_save_error + + " — после перезапуска службы ссылка и интервал вернутся к значениям из .env"); + } + alert.classList.toggle("warn", !state.error && Boolean(state.settings_save_error)); + alert.textContent = warnings.join(" "); + alert.classList.toggle("hidden", !warnings.length); + + // ссылка + $("link").value = state.link || ""; + const open = $("link-open"); + open.href = state.link || "#"; + open.classList.toggle("hidden", !state.link); + + // обновление + $("interval").value = state.interval_minutes; + setText("fact-changed", state.changed ? "изменился" : "без изменений", state.changed ? "good" : null); + setText("fact-modified", fmt(state.table_modified)); + setText("fact-hash", state.content_hash || "—"); + + // входы const attempt = state.last_attempt; - auth.appendChild(chip( - "Последняя попытка входа", - loginText(attempt), - attempt ? (attempt.ok ? "good" : "bad") : null, - )); - auth.appendChild(chip("Последний успешный вход", loginText(state.last_success))); + setText("auth-attempt", loginText(attempt), attempt ? (attempt.ok ? "good" : "bad") : null); + setText("auth-success", loginText(state.last_success)); const throttle = state.throttle || {}; if (throttle.locked_until) { - auth.appendChild(chip("Вход заблокирован до", fmt(throttle.locked_until), "bad")); + setText("auth-throttle", `заблокировано до ${fmt(throttle.locked_until)}`, "bad"); } else if (throttle.max_attempts) { - auth.appendChild(chip( - "Осталось попыток", - `${throttle.attempts_left} из ${throttle.max_attempts}`, - )); + setText("auth-throttle", `${throttle.attempts_left} из ${throttle.max_attempts}`); } - $("link").value = state.link || ""; - $("interval").value = state.interval_minutes; - - const err = $("state-error"); - err.textContent = state.error ? "Ошибка загрузки: " + state.error : ""; - err.classList.toggle("hidden", !state.error); - - const warn = $("state-warn"); - warn.textContent = state.settings_save_error - ? "Настройки не сохраняются на диск: " + state.settings_save_error + - " — после перезапуска службы ссылка и интервал вернутся к значениям из .env." - : ""; - warn.classList.toggle("hidden", !state.settings_save_error); + // данные + setText("fact-groups", String(state.groups)); + setText("fact-updated", state.last_updated ? `${fmt(state.last_updated)} (${ago(state.last_updated)})` : "—"); + setText("fact-settings", state.settings_file || "—"); } function showLogin(show) { @@ -278,6 +398,11 @@ return () => { button.disabled = false; button.textContent = original; }; } + async function withButton(button, text, fn) { + const done = busy(button, text); + try { await fn(); } finally { done(); } + } + async function loadState() { const r = await api("/state"); if (r.status === 401) { showLogin(true); return; } @@ -289,11 +414,6 @@ renderState(r.data); } - async function withButton(button, text, fn) { - const done = busy(button, text); - try { await fn(); } finally { done(); } - } - $("login-form").addEventListener("submit", async (e) => { e.preventDefault(); const error = $("login-error"); @@ -350,6 +470,13 @@ showLogin(true); }); + $("theme-toggle").addEventListener("click", () => { + const current = document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light"; + const next = current === "dark" ? "light" : "dark"; + document.documentElement.setAttribute("data-theme", next); + try { localStorage.setItem("theme", next); } catch (_) {} + }); + loadState();