redesign settings page UX/UI: structured cards, consistent fields, sticky save bar, backup stats and loading states
This commit is contained in:
+165
-65
@@ -10,92 +10,176 @@
|
||||
<div class="layout">
|
||||
<div class="sidebar" id="sidebar"></div>
|
||||
<div class="main">
|
||||
<h2>Настройки</h2>
|
||||
<div class="settings-card">
|
||||
<div class="settings-row">
|
||||
<div>
|
||||
<label for="spamInterval">Защита от повторов (минут)</label>
|
||||
<p class="hint">Если ученик уже отправил ответ, следующая отправка от него будет отклонена в течение этого времени. 0 — отключить.</p>
|
||||
</div>
|
||||
<input type="number" id="spamInterval" min="0" max="1440" style="width:120px;padding:10px 14px;border:1px solid var(--border);border-radius:8px;background:var(--card);color:var(--text);font-size:.9rem">
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;margin-top:24px">
|
||||
<button class="btn-link" onclick="saveSettings()">Сохранить</button>
|
||||
</div>
|
||||
<div class="page-head">
|
||||
<h2>Настройки</h2>
|
||||
<p class="page-sub">Параметры формы и данные системы</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 style="margin:0 0 8px">Футер на форме</h3>
|
||||
<p class="hint" style="margin-bottom:16px">Тексты показываются внизу формы ответа ученика.</p>
|
||||
<div class="settings-row">
|
||||
<div>
|
||||
<label for="footerLeft">Текст слева</label>
|
||||
<p class="hint">Нижняя часть формы, слева</p>
|
||||
<div class="settings-stack">
|
||||
<div class="settings-card">
|
||||
<div class="card-head">
|
||||
<div class="ic">🛡️</div>
|
||||
<h3>Защита от повторов</h3>
|
||||
</div>
|
||||
<input type="text" id="footerLeft" style="width:280px;padding:10px 14px;border:1px solid var(--border);border-radius:8px;background:var(--card);color:var(--text);font-size:.9rem" placeholder="">
|
||||
</div>
|
||||
<div class="settings-row" style="margin-top:16px">
|
||||
<div>
|
||||
<label for="footerRight">Текст справа</label>
|
||||
<p class="hint">Нижняя часть формы, справа</p>
|
||||
<p class="hint">Если ученик уже отправил ответ, следующая отправка от него будет отклонена в течение заданного времени. 0 — отключить.</p>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field">
|
||||
<label for="spamInterval">Интервал ожидания</label>
|
||||
<div class="unit-row">
|
||||
<input type="number" id="spamInterval" class="settings-input" min="0" max="1440" step="1">
|
||||
<span class="unit">минут</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input type="text" id="footerRight" style="width:280px;padding:10px 14px;border:1px solid var(--border);border-radius:8px;background:var(--card);color:var(--text);font-size:.9rem" placeholder="">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 style="margin:0 0 8px">Резервная копия</h3>
|
||||
<p class="hint">Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Восстановление полностью заменит текущие данные.</p>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:16px">
|
||||
<button class="btn-link" onclick="downloadBackup()">Скачать бэкап</button>
|
||||
<button class="btn-link" onclick="document.getElementById('restoreFile').click()">Восстановить из файла</button>
|
||||
<input type="file" id="restoreFile" accept=".tar.gz,.gz,application/gzip" style="display:none">
|
||||
<div class="settings-card">
|
||||
<div class="card-head">
|
||||
<div class="ic">📄</div>
|
||||
<h3>Футер на форме</h3>
|
||||
</div>
|
||||
<p class="hint">Тексты показываются внизу формы ответа ученика.</p>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field">
|
||||
<label for="footerLeft">Текст слева</label>
|
||||
<input type="text" id="footerLeft" class="settings-input" placeholder="Например: Поле, руководитель">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="footerRight">Текст справа</label>
|
||||
<input type="text" id="footerRight" class="settings-input" placeholder="Например: Год, курс">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="card-head">
|
||||
<div class="ic">🗄️</div>
|
||||
<h3>Резервное копирование</h3>
|
||||
</div>
|
||||
<p class="hint">Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Восстановление полностью заменит текущие данные.</p>
|
||||
<div class="data-stats" id="dataStats"><span class="chip">Загрузка статистики…</span></div>
|
||||
<div class="card-foot">
|
||||
<button class="btn-primary ghost" id="backupBtn" onclick="downloadBackup()">⬇️ Скачать бэкап</button>
|
||||
<button class="btn-primary ghost" id="restoreBtn" onclick="document.getElementById('restoreFile').click()">⬆️ Восстановить из файла</button>
|
||||
<input type="file" id="restoreFile" accept=".tar.gz,.gz,application/gzip" style="display:none">
|
||||
</div>
|
||||
<div class="restore-status" id="restoreStatus"></div>
|
||||
</div>
|
||||
<div id="restoreStatus" class="hint" style="margin-top:12px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="save-bar" id="saveBar">
|
||||
<span class="hint" id="saveHint">⚠️ Есть несохранённые изменения</span>
|
||||
<button class="btn-primary" id="saveBtn" onclick="saveSettings()">Сохранить изменения</button>
|
||||
</div>
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="admin.js"></script>
|
||||
<script>
|
||||
const DIRTY_FIELDS = ['spamInterval', 'footerLeft', 'footerRight'];
|
||||
|
||||
function setBtnLoading(btn, on, spinnerClass) {
|
||||
if (on) {
|
||||
btn.dataset.label = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = `<span class="spinner ${spinnerClass || ''}"></span> ${btn.dataset.loadingText || 'Обработка...'}`;
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = btn.dataset.label || btn.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
function updateDirty() {
|
||||
const dirty = DIRTY_FIELDS.some(id => {
|
||||
const el = document.getElementById(id);
|
||||
return String(el.dataset.original ?? '') !== el.value;
|
||||
});
|
||||
document.getElementById('saveBar').classList.toggle('show', dirty);
|
||||
}
|
||||
|
||||
function bindDirty() {
|
||||
DIRTY_FIELDS.forEach(id => {
|
||||
document.getElementById(id).addEventListener('input', updateDirty);
|
||||
});
|
||||
}
|
||||
|
||||
function snapshotDirty() {
|
||||
DIRTY_FIELDS.forEach(id => {
|
||||
document.getElementById(id).dataset.original = document.getElementById(id).value;
|
||||
});
|
||||
updateDirty();
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
const res = await fetch(`${API}/api/settings`, { headers: hdr() });
|
||||
const s = await res.json();
|
||||
const [sRes, stRes] = await Promise.all([
|
||||
fetch(`${API}/api/settings`, { headers: hdr() }),
|
||||
fetch(`${API}/api/stats`, { headers: hdr() }),
|
||||
]);
|
||||
const s = await sRes.json();
|
||||
if (s.spam_interval_min !== undefined) document.getElementById('spamInterval').value = s.spam_interval_min;
|
||||
document.getElementById('footerLeft').value = s.footer_left || '';
|
||||
document.getElementById('footerRight').value = s.footer_right || '';
|
||||
snapshotDirty();
|
||||
if (stRes.ok) {
|
||||
const st = await stRes.json();
|
||||
document.getElementById('dataStats').innerHTML = `
|
||||
<span class="chip">Записей: <b>${st.entries}</b></span>
|
||||
<span class="chip">Учеников: <b>${st.students}</b></span>
|
||||
<span class="chip">Групп: <b>${st.groups}</b></span>
|
||||
<span class="chip">Сегодня: <b>${st.today}</b></span>
|
||||
<span class="chip">${st.trash ? `В корзине: <b>${st.trash}</b> <a href="trash.html">→</a>` : ''}</span>
|
||||
`;
|
||||
} else {
|
||||
document.getElementById('dataStats').innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
const v = document.getElementById('spamInterval').value;
|
||||
if (v === '' || Number(v) < 0) { alert('Введите число от 0'); return; }
|
||||
const res = await fetch(`${API}/api/settings`, {
|
||||
method: 'PUT',
|
||||
headers: hdrJson(),
|
||||
body: JSON.stringify({ settings: {
|
||||
spam_interval_min: String(Math.round(Number(v))),
|
||||
footer_left: document.getElementById('footerLeft').value.trim(),
|
||||
footer_right: document.getElementById('footerRight').value.trim(),
|
||||
} })
|
||||
});
|
||||
if (res.ok) showToast('Настройки сохранены');
|
||||
else { const e = await res.json(); alert(e.error); }
|
||||
const btn = document.getElementById('saveBtn');
|
||||
btn.dataset.loadingText = 'Сохранение...';
|
||||
setBtnLoading(btn, true);
|
||||
try {
|
||||
const res = await fetch(`${API}/api/settings`, {
|
||||
method: 'PUT',
|
||||
headers: hdrJson(),
|
||||
body: JSON.stringify({ settings: {
|
||||
spam_interval_min: String(Math.round(Number(v))),
|
||||
footer_left: document.getElementById('footerLeft').value.trim(),
|
||||
footer_right: document.getElementById('footerRight').value.trim(),
|
||||
} })
|
||||
});
|
||||
if (res.ok) {
|
||||
showToast('Настройки сохранены');
|
||||
snapshotDirty();
|
||||
} else { const e = await res.json(); alert(e.error); }
|
||||
} finally {
|
||||
setBtnLoading(btn, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadBackup() {
|
||||
const res = await fetch(`${API}/api/backup`, { headers: hdr() });
|
||||
if (!res.ok) { const t = await res.text(); alert('Ошибка: ' + t); return; }
|
||||
const blob = await res.blob();
|
||||
const cd = res.headers.get('Content-Disposition') || '';
|
||||
const m = cd.match(/filename="([^"]+)"/);
|
||||
const name = m ? m[1] : `whatido-backup-${new Date().toISOString().slice(0, 10)}.json.gz`;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast('Бэкап скачан');
|
||||
const btn = document.getElementById('backupBtn');
|
||||
btn.dataset.loadingText = 'Формирование...';
|
||||
setBtnLoading(btn, true);
|
||||
try {
|
||||
const res = await fetch(`${API}/api/backup`, { headers: hdr() });
|
||||
if (!res.ok) { const t = await res.text(); alert('Ошибка: ' + t); return; }
|
||||
const blob = await res.blob();
|
||||
const cd = res.headers.get('Content-Disposition') || '';
|
||||
const m = cd.match(/filename="([^"]+)"/);
|
||||
const name = m ? m[1] : `whatido-backup-${new Date().toISOString().slice(0, 10)}.json.gz`;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast('Бэкап скачан');
|
||||
} finally {
|
||||
setBtnLoading(btn, false);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('restoreFile').addEventListener('change', async (e) => {
|
||||
@@ -103,7 +187,12 @@ document.getElementById('restoreFile').addEventListener('change', async (e) => {
|
||||
if (!file) return;
|
||||
if (!confirm('Восстановление заменит все текущие данные. Продолжить?')) { e.target.value = ''; return; }
|
||||
const st = document.getElementById('restoreStatus');
|
||||
st.textContent = 'Восстановление...';
|
||||
const backupBtn = document.getElementById('backupBtn');
|
||||
const restoreBtn = document.getElementById('restoreBtn');
|
||||
st.classList.remove('err');
|
||||
st.innerHTML = '<span class="spinner dark"></span> Восстановление...';
|
||||
backupBtn.disabled = true;
|
||||
restoreBtn.disabled = true;
|
||||
const fd = new FormData();
|
||||
fd.append('backup', file);
|
||||
let res;
|
||||
@@ -114,16 +203,27 @@ document.getElementById('restoreFile').addEventListener('change', async (e) => {
|
||||
alert('Ошибка сети: ' + err.message);
|
||||
e.target.value = '';
|
||||
return;
|
||||
} finally {
|
||||
backupBtn.disabled = false;
|
||||
restoreBtn.disabled = false;
|
||||
}
|
||||
if (res.ok) {
|
||||
showToast('Данные восстановлены');
|
||||
st.textContent = '';
|
||||
loadSettings();
|
||||
} else {
|
||||
st.classList.add('err');
|
||||
st.innerHTML = 'Ошибка восстановления';
|
||||
const t = await res.text();
|
||||
alert('Ошибка: ' + t);
|
||||
}
|
||||
st.textContent = '';
|
||||
if (res.ok) showToast('Данные восстановлены');
|
||||
else { const t = await res.text(); alert('Ошибка: ' + t); }
|
||||
e.target.value = '';
|
||||
});
|
||||
|
||||
(async () => {
|
||||
if (await checkAuth()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
bindDirty();
|
||||
loadSettings();
|
||||
}
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user