Files
schedule/src/frontend/admin.html
Vladislav Kopnin 71f83a27dd Админка: страница переделана — понятнее и в стиле сайта
- шапка как на сайте (логотип факультета, «на сайт», переключатель светлой/тёмной
  темы — тема общая с сайтом и не мигает при загрузке);
- крупная шапка состояния: «Работает / Ожу данные / Ошибка загрузки», сколько
  обновлено назад, число групп и текущий интервал; рядом главная кнопка
  «Обновить сейчас»;
- вместо россыпи чипов — карточки: «Ссылка на расписание» (с кнопкой
  «открыть таблицу»), «Обновление» (интервал + факты: изменился ли файл, дата,
  отпечаток), «Входы в админку» (последняя попытка, последний успешный вход,
  остаток попыток или блокировка), «Данные расписания»;
- предупреждения (ошибка загрузки, невозможность сохранить настройки) вынесены
  отдельной плашкой под шапкой, а не спрятаны среди полей;
- верстка адаптивная: сетка карточек, на телефоне одна колонка и подписи
  под значениями; формы с подписями и подсказками, кнопки с ясной иерархией;
- уведомления и вся логика прежние; тесты 160.
2026-10-10 12:42:44 +03:00

484 lines
23 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>Админка расписания</title>
<link rel="icon" type="image/svg+xml" href="/img/app-icon.svg">
<link rel="stylesheet" href="/style.css">
<script>
// тему применяем до отрисовки, чтобы не мигало
try {
const saved = localStorage.getItem("theme");
document.documentElement.setAttribute("data-theme", saved === "dark" ? "dark" : "light");
} catch (_) {}
</script>
<style>
body { background: var(--bg); }
.admin-main { max-width: 1080px; margin: 0 auto; padding: 22px 18px 70px; }
.admin-actions { display: flex; align-items: center; gap: 8px; }
.admin-ghost {
display: inline-flex; align-items: center; height: 36px; padding: 0 12px;
border: 1px solid var(--border-2); background: var(--surface); color: var(--muted);
font-size: 13px; font-weight: 600; text-decoration: none;
}
.admin-ghost:hover { color: var(--accent); border-color: var(--accent); }
/* ---- вход ---- */
.admin-login {
max-width: 420px; margin: 6vh auto 0; padding: 30px 28px 26px;
background: var(--surface); border: 1px solid var(--border);
box-shadow: 0 14px 40px var(--shadow); text-align: center;
}
.admin-login-logo {
width: 58px; height: 58px; padding: 10px; margin: 0 auto 14px;
background: linear-gradient(160deg, var(--nav-1), var(--nav-2));
border-radius: 14px; display: block;
}
.admin-login h1 { font-size: 20px; color: var(--text); margin-bottom: 6px; }
.admin-login-hint { font-size: 13px; color: var(--muted); margin-bottom: 20px; }
.admin-login form { display: flex; flex-direction: column; gap: 12px; text-align: left; }
.admin-login .admin-btn { margin-top: 4px; width: 100%; }
/* ---- шапка состояния ---- */
.admin-hero {
display: flex; align-items: center; justify-content: space-between; gap: 16px;
flex-wrap: wrap;
background: var(--surface); border: 1px solid var(--border); border-left: 4px solid var(--accent);
padding: 16px 18px; margin-bottom: 16px;
}
.admin-hero.is-ok { border-left-color: #2f9e5f; }
.admin-hero.is-error { border-left-color: var(--accent-red-bright); }
.admin-hero-state { display: flex; align-items: center; gap: 9px; font-size: 18px; font-weight: 700; color: var(--text); }
.admin-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--muted-2); }
.admin-hero.is-ok .admin-dot { background: #2f9e5f; }
.admin-hero.is-error .admin-dot { background: var(--accent-red-bright); }
.admin-hero-sub { color: var(--muted); font-size: 13px; margin-top: 5px; }
/* ---- карточки ---- */
.admin-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(330px, 1fr)); gap: 14px; }
.admin-card {
background: var(--surface); border: 1px solid var(--border); padding: 16px 18px 14px;
display: flex; flex-direction: column; gap: 10px; min-width: 0;
}
.admin-card h2 {
font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px;
color: var(--muted); font-weight: 700;
}
.admin-card-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.admin-inline { display: flex; align-items: flex-end; gap: 10px; flex-wrap: wrap; }
.admin-field { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
.admin-field > span { font-size: 12px; color: var(--muted); }
.admin-field-narrow { flex: 0 1 160px; }
.admin-field input {
font-size: 14px; padding: 9px 11px; width: 100%; min-width: 0;
border: 1px solid var(--border-2); background: var(--surface-2); color: var(--text);
}
.admin-field input:focus { outline: 2px solid var(--accent-blue); outline-offset: -1px; }
.admin-btn {
font-size: 14px; font-weight: 600; padding: 9px 14px; cursor: pointer; white-space: nowrap;
border: 1px solid var(--accent); background: var(--accent); color: var(--on-accent);
}
.admin-btn:hover { filter: brightness(1.06); }
.admin-btn.secondary { background: var(--surface-2); color: var(--text); border-color: var(--border-2); }
.admin-btn.danger { background: transparent; color: var(--accent-red-bright); border-color: var(--accent-red-bright); }
.admin-btn-lg { font-size: 15px; padding: 11px 18px; }
.admin-btn[disabled] { opacity: .55; cursor: default; filter: none; }
.admin-link { font-size: 13px; color: var(--accent-blue); text-decoration: none; }
.admin-link:hover { text-decoration: underline; }
/* ---- факты ---- */
.admin-facts { display: flex; flex-direction: column; gap: 6px; margin: 0; }
.admin-facts > div { display: flex; gap: 12px; justify-content: space-between; align-items: baseline; }
.admin-facts dt { font-size: 13px; color: var(--muted); flex: 0 0 auto; }
.admin-facts dd {
margin: 0; font-size: 14px; color: var(--text); font-weight: 600; text-align: right;
min-width: 0; overflow-wrap: anywhere;
}
.admin-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-weight: 500; font-size: 13px; }
.good { color: #2f9e5f; }
.bad { color: var(--accent-red-bright); }
.admin-hint { font-size: 12px; color: var(--muted-2); line-height: 1.45; }
.admin-alert {
font-size: 13px; padding: 10px 12px; margin-bottom: 14px;
background: var(--error-bg); border: 1px solid var(--error-border); color: var(--error-text);
overflow-wrap: anywhere;
}
.admin-alert.warn { background: color-mix(in srgb, #b8860b 12%, var(--surface)); border-color: #b8860b; color: #8a6300; }
.hidden { display: none !important; }
/* ---- уведомления ---- */
#toast-wrap {
position: fixed; left: 50%; bottom: 18px; transform: translateX(-50%);
z-index: 9999; display: flex; flex-direction: column; gap: 8px;
width: min(560px, calc(100vw - 32px));
}
.toast {
display: flex; align-items: flex-start; gap: 10px;
background: var(--surface); border: 1px solid var(--border-2); border-left: 4px solid var(--accent-blue);
box-shadow: 0 10px 30px var(--shadow);
padding: 12px 14px; font-size: 14px; color: var(--text);
white-space: pre-line; animation: toast-in .18s ease-out;
}
.toast.ok { border-left-color: #2f9e5f; }
.toast.error { border-left-color: var(--accent-red-bright); }
.toast.hide { opacity: 0; transition: opacity .3s ease; }
.toast .toast-text { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.toast .toast-close {
flex: 0 0 auto; border: none; background: none; color: var(--muted);
font-size: 15px; line-height: 1; cursor: pointer; padding: 2px 4px;
}
.toast .toast-close:hover { color: var(--text); }
@keyframes toast-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
@media (max-width: 640px) {
.admin-main { padding: 16px 12px 60px; }
.admin-hero { padding: 14px; }
.admin-hero .admin-btn { width: 100%; }
.admin-login { margin-top: 2vh; padding: 24px 18px; }
.admin-facts > div { flex-direction: column; gap: 2px; }
.admin-facts dd { text-align: left; }
}
</style>
</head>
<body>
<div class="topbar">
<div class="topbar-brand">
<img class="topbar-logo" src="/img/owl.svg" alt="">
<div class="topbar-text">
<span class="topbar-university">Расписание — админка</span>
<span class="topbar-faculty">источник данных и обновление</span>
</div>
</div>
<div class="admin-actions">
<a class="admin-ghost" href="/">← на сайт</a>
<button class="theme-toggle" id="theme-toggle" title="Переключить тему">
<svg class="icon icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
<svg class="icon icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</button>
</div>
</div>
<main class="admin-main">
<section id="login-card" class="admin-login hidden">
<img class="admin-login-logo" src="/img/owl-white.svg" alt="">
<h1>Вход в админку</h1>
<p class="admin-login-hint">Страница открывается только по секретной ссылке. После нескольких неудачных попыток вход временно блокируется.</p>
<form id="login-form" autocomplete="on">
<label class="admin-field"><span>Логин</span><input id="login" autocomplete="username" required></label>
<label class="admin-field"><span>Пароль</span><input id="password" type="password" autocomplete="current-password" required></label>
<button class="admin-btn" type="submit">Войти</button>
</form>
<div id="login-error" class="admin-alert hidden" style="margin:14px 0 0"></div>
</section>
<section id="panel" class="hidden">
<div class="admin-hero" id="hero">
<div>
<div class="admin-hero-state"><span class="admin-dot"></span><span id="hero-title">Загружаю…</span></div>
<div class="admin-hero-sub" id="hero-sub"></div>
</div>
<button class="admin-btn admin-btn-lg" id="refresh" type="button">Обновить сейчас</button>
</div>
<div id="hero-alert" class="admin-alert hidden"></div>
<div class="admin-grid">
<article class="admin-card">
<h2>Ссылка на расписание</h2>
<label class="admin-field"><span>Публичная ссылка Яндекс.Диска</span>
<input id="link" spellcheck="false" placeholder="https://disk.yandex.ru/i/...">
</label>
<div class="admin-card-actions">
<button class="admin-btn" id="save-link" type="button">Сохранить ссылку</button>
<a class="admin-link" id="link-open" href="#" target="_blank" rel="noopener">открыть таблицу</a>
</div>
<p class="admin-hint">Ссылка проверяется скачиванием: если не открывается — не сохранится, данные на сайте останутся прежними.
Значение хранится в файле настроек и <b>имеет приоритет над .env</b>.</p>
</article>
<article class="admin-card">
<h2>Обновление</h2>
<div class="admin-inline">
<label class="admin-field admin-field-narrow"><span>Интервал, минут</span>
<input id="interval" type="number" min="1" max="1440" step="1">
</label>
<button class="admin-btn secondary" id="save-interval" type="button">Сохранить</button>
</div>
<dl class="admin-facts">
<div><dt>Файл при последнем обновлении</dt><dd id="fact-changed">—</dd></div>
<div><dt>Дата файла на Диске</dt><dd id="fact-modified">—</dd></div>
<div><dt>Отпечаток файла</dt><dd id="fact-hash" class="admin-mono">—</dd></div>
</dl>
<p class="admin-hint">По интервалу таблица перечитывается, только если на Диске изменилась дата файла.
Кнопка «Обновить сейчас» скачивает таблицу всегда — её и нажимайте сразу после правок.</p>
</article>
<article class="admin-card">
<h2>Входы в админку</h2>
<dl class="admin-facts">
<div><dt>Последняя попытка</dt><dd id="auth-attempt">—</dd></div>
<div><dt>Последний успешный вход</dt><dd id="auth-success">—</dd></div>
<div><dt>Осталось попыток</dt><dd id="auth-throttle">—</dd></div>
</dl>
<div class="admin-card-actions" style="margin-top:auto">
<button class="admin-btn danger" id="logout" type="button">Выйти</button>
</div>
<p class="admin-hint">Каждая попытка запоминается: откуда, когда и чем закончилась. Порог и время блокировки задаются в .env.</p>
</article>
<article class="admin-card">
<h2>Данные расписания</h2>
<dl class="admin-facts">
<div><dt>Групп</dt><dd id="fact-groups">—</dd></div>
<div><dt>Обновлено</dt><dd id="fact-updated">—</dd></div>
<div><dt>Файл настроек</dt><dd id="fact-settings" class="admin-mono">—</dd></div>
</dl>
</article>
</div>
</section>
</main>
<div id="toast-wrap"></div>
<script>
const $ = (id) => document.getElementById(id);
const TOAST_MS = 15000;
/** Всплывающее уведомление: само скрывается через 15 секунд. */
function toast(message, kind = "info", ms = TOAST_MS) {
const wrap = $("toast-wrap");
const el = document.createElement("div");
el.className = "toast " + kind;
const text = document.createElement("div");
text.className = "toast-text";
text.textContent = message;
const close = document.createElement("button");
close.type = "button";
close.className = "toast-close";
close.textContent = "✕";
close.setAttribute("aria-label", "Закрыть");
el.append(text, close);
wrap.appendChild(el);
const hide = () => {
clearTimeout(timer);
el.classList.add("hide");
setTimeout(() => el.remove(), 300);
};
const timer = setTimeout(hide, ms);
close.addEventListener("click", hide);
return hide;
}
async function api(path, options = {}) {
let resp;
try {
resp = await fetch("/api/admin" + path, { credentials: "same-origin", ...options });
} catch (err) {
return { ok: false, status: 0, data: { detail: "Сеть недоступна: " + err.message } };
}
let data = null;
try { data = await resp.json(); } catch (_) {}
return { ok: resp.ok, status: resp.status, data };
}
function post(path, payload) {
return api(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload || {}),
});
}
function fmt(value) {
if (!value) return "—";
const d = new Date(value);
return isNaN(d) ? String(value) : d.toLocaleString("ru-RU", { dateStyle: "short", timeStyle: "short" });
}
/** «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 "нет данных";
const status = info.ok === undefined ? "" : ` · ${info.ok ? "успешно" : "неудачно"}`;
return `${fmt(info.at)} · ${info.ip}${status}`;
}
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);
}
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;
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) {
setText("auth-throttle", `заблокировано до ${fmt(throttle.locked_until)}`, "bad");
} else if (throttle.max_attempts) {
setText("auth-throttle", `${throttle.attempts_left} из ${throttle.max_attempts}`);
}
// данные
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) {
$("login-card").classList.toggle("hidden", !show);
$("panel").classList.toggle("hidden", show);
if (show) $("login").focus();
}
function busy(button, text) {
const original = button.textContent;
button.disabled = true;
button.textContent = text;
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; }
if (!r.ok) {
toast("Не удалось получить состояние: " + ((r.data && r.data.detail) || r.status), "error");
return;
}
showLogin(false);
renderState(r.data);
}
$("login-form").addEventListener("submit", async (e) => {
e.preventDefault();
const error = $("login-error");
error.classList.add("hidden");
const r = await post("/login", { login: $("login").value, password: $("password").value });
if (!r.ok) {
error.textContent = (r.data && r.data.detail) || "Не удалось войти";
error.classList.remove("hidden");
return;
}
$("password").value = "";
toast("Вход выполнен.", "ok", 5000);
await loadState();
});
$("save-link").addEventListener("click", () => withButton($("save-link"), "Проверяю…", async () => {
const r = await post("/link", { link: $("link").value });
if (!r.ok) {
toast("Ссылка не принята: " + ((r.data && r.data.detail) || r.status), "error");
return;
}
renderState(r.data);
toast("Ссылка сохранена, расписание загружено.", "ok");
}));
$("save-interval").addEventListener("click", () => withButton($("save-interval"), "Сохраняю…", async () => {
const r = await post("/interval", { interval_minutes: Number($("interval").value) });
if (!r.ok) {
toast("Интервал не сохранён: " + ((r.data && r.data.detail) || r.status), "error");
return;
}
toast(`Интервал обновления: ${r.data.interval_minutes} мин.`, "ok");
await loadState();
}));
$("refresh").addEventListener("click", () => withButton($("refresh"), "Обновляю…", async () => {
const r = await post("/refresh");
if (!r.ok) {
toast("Обновить не удалось: " + ((r.data && r.data.detail) || r.status), "error");
} else if (r.data) {
renderState(r.data);
toast(
r.data.changed
? "Готово: в файле обнаружены изменения — расписание обновлено."
: "Готово: файл не изменился (на Диске та же версия).\nЕсли вы только что правили таблицу, значит Диск ещё не отдаёт новую версию — попробуйте через минуту.",
r.data.changed ? "ok" : "info",
);
}
await loadState();
}));
$("logout").addEventListener("click", async () => {
await post("/logout");
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();
</script>
</body>
</html>