Update project files
This commit is contained in:
+12
-3
@@ -24,6 +24,9 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
||||
.stat{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:20px}
|
||||
.stat .num{font-size:2rem;font-weight:700;color:var(--accent)}
|
||||
.stat .label{color:var(--muted);font-size:.8rem;margin-top:4px}
|
||||
.stat .stat-sub{font-size:1rem;color:var(--muted);font-weight:600}
|
||||
.stat-bar{height:6px;background:var(--border);border-radius:3px;overflow:hidden;margin-top:14px}
|
||||
.stat-bar i{display:block;height:100%;border-radius:3px}
|
||||
|
||||
.filters{display:flex;gap:8px;margin-bottom:20px;flex-wrap:wrap;align-items:flex-end}
|
||||
.filters select,.filters input[type=date]{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem}
|
||||
@@ -190,9 +193,12 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
||||
.gallery-foot .cancel{background:var(--border);color:var(--text);border:none;border-radius:8px;padding:10px 20px;font-size:.9rem;font-weight:600;cursor:pointer;transition:opacity .15s}
|
||||
.gallery-foot .cancel:hover{opacity:.85}
|
||||
.photo-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;overflow:auto;padding:2px}
|
||||
.photo-item{background:var(--bg);border:1px solid var(--border);border-radius:10px;overflow:hidden;display:flex;flex-direction:column}
|
||||
.photo-item img{width:100%;aspect-ratio:4/3;object-fit:cover;cursor:pointer;display:block;transition:opacity .15s}
|
||||
.photo-item img:hover{opacity:.9}
|
||||
.photo-item{position:relative;background:var(--bg);border:1px solid var(--border);border-radius:10px;overflow:hidden;display:flex;flex-direction:column;cursor:grab}
|
||||
.photo-item.dragging{opacity:.5;border-color:#8b5cf6;box-shadow:0 0 0 2px rgba(139,92,246,.3)}
|
||||
.photo-item.drag-over{border-color:#8b5cf6}
|
||||
.photo-item img{width:100%;aspect-ratio:4/3;object-fit:cover;cursor:grab;display:block;transition:opacity .15s}
|
||||
.photo-item:hover img{opacity:.9}
|
||||
.photo-item .drag-hint{position:absolute;top:6px;left:6px;z-index:2;background:rgba(0,0,0,.55);color:#fff;border-radius:6px;padding:2px 7px;font-size:.85rem;line-height:1.2;cursor:grab;user-select:none;pointer-events:none}
|
||||
.photo-item .p-cap{padding:8px 10px;font-size:.78rem;color:var(--text);min-height:1.2em;white-space:pre-wrap;word-break:break-word}
|
||||
.photo-item .p-date{font-size:.72rem;color:var(--muted);padding:0 10px 6px}
|
||||
.photo-item .p-actions{display:flex;justify-content:flex-end;gap:4px;padding:6px 8px;border-top:1px solid var(--border)}
|
||||
@@ -226,6 +232,9 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
||||
.page-head h2{font-size:1.3rem;font-weight:600;margin:0}
|
||||
.page-sub{color:var(--muted);font-size:.85rem}
|
||||
.settings-stack{display:flex;flex-direction:column;gap:20px;max-width:680px}
|
||||
.settings-stack-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:20px;max-width:1200px}
|
||||
.settings-stack-grid > .settings-card{max-width:none;height:100%}
|
||||
.settings-stack-grid > .settings-card.wide{grid-column:1/-1}
|
||||
.settings-card .card-head{display:flex;align-items:center;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--border);margin-top:-2px}
|
||||
.settings-card .card-head .ic{width:38px;height:38px;border-radius:11px;background:rgba(37,99,235,.08);display:flex;align-items:center;justify-content:center;font-size:1.15rem;flex-shrink:0}
|
||||
.settings-card .card-head h3{font-size:1.05rem;font-weight:700;margin:0}
|
||||
|
||||
+50
-22
@@ -1,44 +1,71 @@
|
||||
const API = location.origin;
|
||||
let token = sessionStorage.getItem('adminToken');
|
||||
function hdr() { return { 'X-Admin-Token': token }; }
|
||||
function hdrJson() { return { 'Content-Type': 'application/json', 'X-Admin-Token': token }; }
|
||||
let token = sessionStorage.getItem('authToken');
|
||||
let currentUser = null;
|
||||
function hdr() { return { 'X-Auth-Token': token }; }
|
||||
function hdrJson() { return { 'Content-Type': 'application/json', 'X-Auth-Token': token }; }
|
||||
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
|
||||
// --- Auth check ---
|
||||
async function checkAuth() {
|
||||
if (!token) { location.href = 'login.html'; return false; }
|
||||
try {
|
||||
const res = await fetch(`${API}/api/stats`, { headers: hdr() });
|
||||
if (!res.ok) { sessionStorage.removeItem('adminToken'); location.href = 'login.html'; return false; }
|
||||
const res = await fetch(`${API}/api/auth/me`, { headers: hdr() });
|
||||
if (!res.ok) { cleanupSession(); return false; }
|
||||
currentUser = await res.json();
|
||||
return true;
|
||||
} catch { sessionStorage.removeItem('adminToken'); location.href = 'login.html'; return false; }
|
||||
} catch { cleanupSession(); return false; }
|
||||
}
|
||||
|
||||
function doLogout() {
|
||||
sessionStorage.removeItem('adminToken');
|
||||
function cleanupSession() {
|
||||
sessionStorage.removeItem('authToken');
|
||||
token = null;
|
||||
currentUser = null;
|
||||
location.href = 'login.html';
|
||||
}
|
||||
|
||||
function isAdmin() { return currentUser && currentUser.role === 'admin'; }
|
||||
|
||||
async function requireAdminPage() {
|
||||
if (!(await checkAuth())) return false;
|
||||
if (!isAdmin()) { location.href = 'journal.html'; return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
async function doLogout() {
|
||||
try { await fetch(`${API}/api/auth/logout`, { method: 'POST', headers: hdr() }); } catch {}
|
||||
cleanupSession();
|
||||
}
|
||||
|
||||
// --- Sidebar ---
|
||||
const NAV = [
|
||||
{ page: 'dashboard', label: 'Дашборд' },
|
||||
{ page: 'journal', label: 'Журнал' },
|
||||
{ page: 'trash', label: 'Корзина' },
|
||||
{ page: 'files', label: 'Файлы' },
|
||||
{ page: 'links', label: 'Ссылки' },
|
||||
{ page: 'students', label: 'Ученики' },
|
||||
{ page: 'groups', label: 'Группы' },
|
||||
{ page: 'branches', label: 'Филиалы' },
|
||||
{ page: 'audit', label: 'Аудит' },
|
||||
{ page: 'settings', label: 'Настройки' }
|
||||
];
|
||||
function buildSidebar(active) {
|
||||
const el = document.getElementById('sidebar');
|
||||
if (!el) return;
|
||||
const base = [
|
||||
{ page: 'dashboard', label: 'Дашборд' },
|
||||
{ page: 'journal', label: 'Журнал' },
|
||||
{ page: 'trash', label: 'Корзина' },
|
||||
{ page: 'files', label: 'Файлы' },
|
||||
{ page: 'links', label: 'Ссылки' },
|
||||
{ page: 'students', label: 'Ученики' },
|
||||
{ page: 'groups', label: 'Группы' },
|
||||
];
|
||||
const adminNav = [
|
||||
{ page: 'branches', label: 'Филиалы' },
|
||||
{ page: 'users', label: 'Пользователи' },
|
||||
{ page: 'audit', label: 'Аудит' },
|
||||
{ page: 'settings', label: 'Настройки' },
|
||||
];
|
||||
const nav = [...base, ...(isAdmin() ? adminNav : [])];
|
||||
const userName = currentUser?.name || currentUser?.username || '';
|
||||
const roleLabel = isAdmin() ? 'Администратор' : 'Тутор';
|
||||
el.innerHTML = `
|
||||
<div class="logo">Админ-панель</div>
|
||||
<div class="user-box">
|
||||
<div class="user-name">${esc(userName)}</div>
|
||||
<div class="user-role">${roleLabel}</div>
|
||||
</div>
|
||||
<nav>
|
||||
${NAV.map(n => `<a href="${n.page}.html" class="${n.page === active ? 'active' : ''}">${n.label}</a>`).join('')}
|
||||
${nav.map(n => `<a href="${n.page}.html" class="${n.page === active ? 'active' : ''}">${n.label}</a>`).join('')}
|
||||
</nav>
|
||||
<div class="bottom">
|
||||
<a href="/">На главную</a>
|
||||
@@ -49,6 +76,7 @@ function buildSidebar(active) {
|
||||
// --- Common UI ---
|
||||
function showToast(msg) {
|
||||
const t = document.getElementById('toast');
|
||||
if (!t) return;
|
||||
t.textContent = msg;
|
||||
t.classList.add('show');
|
||||
setTimeout(() => t.classList.remove('show'), 3000);
|
||||
@@ -61,7 +89,7 @@ function showImg(src) {
|
||||
}
|
||||
|
||||
async function fillAutocomplete() {
|
||||
const res = await fetch(`${API}/api/students`);
|
||||
const res = await fetch(`${API}/api/students`, { headers: hdr() });
|
||||
const students = await res.json();
|
||||
const names = students.map(s => s.name);
|
||||
const dlEdit = document.getElementById('studentEditList');
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
<div><label>Тип</label>
|
||||
<div class="view-toggle" id="tabToggle">
|
||||
<button data-mode="attached" type="button">Прикреплённые</button>
|
||||
<button data-mode="detached" type="button">Откреплённые</button>
|
||||
<button data-mode="detached" type="button" id="detachedTab">Откреплённые</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -110,7 +110,7 @@ document.getElementById('branchesGrid').addEventListener('click', e => {
|
||||
});
|
||||
|
||||
(async () => {
|
||||
if (await checkAuth()) {
|
||||
if (await requireAdminPage()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
loadBranches();
|
||||
}
|
||||
|
||||
+14
-2
@@ -6,13 +6,25 @@ function plural(n, one, few, many) {
|
||||
return many;
|
||||
}
|
||||
|
||||
function renderStats(s) {
|
||||
function renderStats(s, disk) {
|
||||
let diskHtml = '';
|
||||
if (disk) {
|
||||
const pct = disk.used_pct;
|
||||
const color = pct > 90 ? '#ef4444' : pct > 70 ? '#f59e0b' : '#16a34a';
|
||||
diskHtml = `
|
||||
<div class="stat">
|
||||
<div>${disk.used} <span class="stat-sub">/ ${disk.total}</span></div>
|
||||
<div class="label">Диск: свободно ${disk.free} (${100 - pct}%)</div>
|
||||
<div class="stat-bar"><i style="width:${pct}%;background:${color}"></i></div>
|
||||
</div>`;
|
||||
}
|
||||
document.getElementById('stats').innerHTML = `
|
||||
<div class="stat"><div class="num">${s.entries}</div><div class="label">Всего записей</div></div>
|
||||
<div class="stat"><div class="num">${s.students}</div><div class="label">Учеников</div></div>
|
||||
<div class="stat"><div class="num">${s.groups}</div><div class="label">Групп</div></div>
|
||||
<div class="stat"><div class="num">${s.today}</div><div class="label">Сегодня</div></div>
|
||||
<div class="stat"><div class="num" style="color:${s.trash ? '#ef4444' : 'var(--muted)'}">${s.trash}</div><div class="label"><a href="trash.html">В корзине →</a></div></div>
|
||||
${diskHtml}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -92,7 +104,7 @@ async function loadDashboard() {
|
||||
const res = await fetch(`${API}/api/dashboard`, { headers: hdr() });
|
||||
if (!res.ok) { document.getElementById('stats').innerHTML = '<div class="dash-empty">Не удалось загрузить данные</div>'; return; }
|
||||
const d = await res.json();
|
||||
renderStats(d.stats);
|
||||
renderStats(d.stats, d.disk);
|
||||
renderActivity(d.activity);
|
||||
renderRecent(d.recent_entries);
|
||||
renderActive(d.active_groups);
|
||||
|
||||
@@ -159,6 +159,9 @@ document.getElementById('grid').addEventListener('click', e => {
|
||||
(async () => {
|
||||
if (await checkAuth()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
const detachedTab = document.getElementById('detachedTab');
|
||||
if (detachedTab) detachedTab.style.display = isAdmin() ? '' : 'none';
|
||||
if (mode === 'detached' && !isAdmin()) { mode = 'attached'; }
|
||||
document.querySelectorAll('#tabToggle button').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.mode === mode);
|
||||
});
|
||||
|
||||
+75
-3
@@ -23,7 +23,7 @@ async function loadGroups() {
|
||||
list.innerHTML = `<div class="group-grid">` + groups.map(g => {
|
||||
const schedSet = g.day_of_week != null && g.time_start && g.time_end;
|
||||
return `
|
||||
<div class="group-card">
|
||||
<div class="group-card" data-group-id="${g.id}">
|
||||
${g.cover_path
|
||||
? `<img class="group-cover" src="${API}${g.cover_path}" data-group-id="${g.id}" data-group-name="${esc(g.name)}" title="Фото группы (хронология)" alt="">`
|
||||
: `<div class="group-cover empty-cover" data-group-id="${g.id}" data-group-name="${esc(g.name)}" title="Фото группы (хронология)">📷</div>`}
|
||||
@@ -149,7 +149,8 @@ function photoHTML(p) {
|
||||
const uploaded = `Загружено: ${new Date(p.created_at).toLocaleDateString('ru')}`;
|
||||
const isCover = currentCover && p.photo_path === currentCover;
|
||||
return `
|
||||
<div class="photo-item" data-photo-id="${p.id}" data-photo-path="${esc(p.photo_path)}">
|
||||
<div class="photo-item" data-photo-id="${p.id}" data-photo-path="${esc(p.photo_path)}" draggable="true">
|
||||
<div class="drag-hint" title="Перетащите, чтобы изменить порядок">⠿</div>
|
||||
<img src="${API}${p.photo_path}" data-img="${API}${p.photo_path}" alt="">
|
||||
<div class="p-cap">${esc(p.caption || '')}</div>
|
||||
<div class="p-date">${taken ? taken + ' · ' : ''}${uploaded}</div>
|
||||
@@ -236,6 +237,8 @@ async function deletePhoto(id) {
|
||||
if (!confirm('Удалить это фото безвозвратно?')) return;
|
||||
const res = await fetch(`${API}/api/groups/${currentGroupId}/photos/${id}`, { method: 'DELETE', headers: hdr() });
|
||||
if (!res.ok) { const e = await res.json(); alert(e.error || 'Ошибка'); return; }
|
||||
const p = photos.find(x => x.id === id);
|
||||
if (p && currentCover === p.photo_path) currentCover = null;
|
||||
showToast('Фото удалено');
|
||||
loadGroupPhotos();
|
||||
loadGroups();
|
||||
@@ -306,6 +309,73 @@ document.getElementById('groupList').addEventListener('click', e => {
|
||||
}
|
||||
});
|
||||
|
||||
function updateGroupCoverUI(coverPath) {
|
||||
const card = document.querySelector(`.group-card[data-group-id="${currentGroupId}"]`);
|
||||
if (!card) return;
|
||||
const g = allGroups.find(x => String(x.id) === String(currentGroupId));
|
||||
const name = g ? g.name : currentGroupName;
|
||||
const existing = card.querySelector('.group-cover');
|
||||
if (existing && existing.tagName === 'IMG') { existing.src = API + coverPath; return; }
|
||||
const el = document.createElement('img');
|
||||
el.className = 'group-cover';
|
||||
el.src = API + coverPath;
|
||||
el.dataset.groupId = String(currentGroupId);
|
||||
el.dataset.groupName = name;
|
||||
el.title = 'Фото группы (хронология)';
|
||||
el.alt = '';
|
||||
if (existing) existing.replaceWith(el);
|
||||
else card.insertBefore(el, card.firstChild);
|
||||
}
|
||||
|
||||
let dragPhotoId = null;
|
||||
|
||||
async function persistPhotoOrder() {
|
||||
const items = [...document.querySelectorAll('#photoGrid .photo-item')];
|
||||
const order = items.map(el => parseInt(el.dataset.photoId, 10));
|
||||
if (JSON.stringify(order) === JSON.stringify(photos.map(p => p.id))) return;
|
||||
const res = await fetch(`${API}/api/groups/${currentGroupId}/photos/reorder`, {
|
||||
method: 'PUT',
|
||||
headers: hdrJson(),
|
||||
body: JSON.stringify({ order })
|
||||
});
|
||||
if (res.ok) showToast('Порядок фото сохранён');
|
||||
else {
|
||||
try { const err = await res.json(); alert(err.error || 'Ошибка'); } catch { alert('Ошибка'); }
|
||||
loadGroupPhotos();
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('photoGrid').addEventListener('dragstart', e => {
|
||||
const item = e.target.closest('.photo-item');
|
||||
if (!item) return;
|
||||
e.stopPropagation();
|
||||
dragPhotoId = parseInt(item.dataset.photoId, 10);
|
||||
item.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', String(dragPhotoId));
|
||||
});
|
||||
|
||||
document.getElementById('photoGrid').addEventListener('dragover', e => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
const target = e.target.closest('.photo-item');
|
||||
const dragged = dragPhotoId != null ? document.querySelector(`.photo-item[data-photo-id="${dragPhotoId}"]`) : null;
|
||||
if (!target || !dragged || target === dragged) return;
|
||||
if (target.contains(dragged)) return;
|
||||
const rect = target.getBoundingClientRect();
|
||||
const grid = document.getElementById('photoGrid');
|
||||
grid.insertBefore(dragged, e.clientX > rect.left + rect.width / 2 ? target.nextSibling : target);
|
||||
});
|
||||
|
||||
document.getElementById('photoGrid').addEventListener('dragend', e => {
|
||||
const item = e.target.closest('.photo-item');
|
||||
if (item) item.classList.remove('dragging');
|
||||
if (dragPhotoId != null) persistPhotoOrder();
|
||||
dragPhotoId = null;
|
||||
});
|
||||
|
||||
document.getElementById('photoGrid').addEventListener('drop', e => e.preventDefault());
|
||||
|
||||
document.getElementById('photoGrid').addEventListener('click', async e => {
|
||||
const coverBtn = e.target.closest('.p-cover');
|
||||
if (coverBtn) {
|
||||
@@ -320,9 +390,11 @@ document.getElementById('photoGrid').addEventListener('click', async e => {
|
||||
if (res.ok) {
|
||||
const g = await res.json();
|
||||
currentCover = g.cover_path || null;
|
||||
const grp = allGroups.find(x => String(x.id) === String(currentGroupId));
|
||||
if (grp) grp.cover_path = currentCover;
|
||||
showToast('Обложка обновлена');
|
||||
updateGroupCoverUI(currentCover);
|
||||
loadGroupPhotos();
|
||||
loadGroups();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(err.error || 'Ошибка');
|
||||
|
||||
+18
-7
@@ -1,20 +1,31 @@
|
||||
const API = location.origin;
|
||||
|
||||
async function doLogin() {
|
||||
const pwd = document.getElementById('pwdInput').value;
|
||||
if (!pwd) return;
|
||||
const username = document.getElementById('userInput').value.trim();
|
||||
const password = document.getElementById('pwdInput').value;
|
||||
if (!username || !password) return;
|
||||
const err = document.getElementById('loginErr');
|
||||
err.style.display = 'none';
|
||||
try {
|
||||
const res = await fetch(`${API}/api/stats`, { headers: { 'X-Admin-Token': pwd } });
|
||||
if (res.ok) {
|
||||
sessionStorage.setItem('adminToken', pwd);
|
||||
const res = await fetch(`${API}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok && data.token) {
|
||||
sessionStorage.setItem('authToken', data.token);
|
||||
location.href = 'dashboard.html';
|
||||
} else {
|
||||
document.getElementById('loginErr').style.display = 'block';
|
||||
err.textContent = data.error || 'Неверный логин или пароль';
|
||||
err.style.display = 'block';
|
||||
}
|
||||
} catch {
|
||||
document.getElementById('loginErr').style.display = 'block';
|
||||
err.textContent = 'Ошибка соединения';
|
||||
err.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('pwdInput').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
|
||||
document.getElementById('userInput').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
|
||||
document.querySelector('.login button').addEventListener('click', doLogin);
|
||||
@@ -102,9 +102,9 @@ function renderSystemInfo(si) {
|
||||
</div>
|
||||
<div class="sys-card">
|
||||
<div class="sys-title">Фото</div>
|
||||
<div class="sys-item"><span class="sys-label">Фото групп</span><span class="sys-value">${photos.group_photos?.count || 0}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Фото в записях</span><span class="sys-value">${photos.entry_photos?.count || 0}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Всего</span><span class="sys-value"><b>${photos.total_count || 0}</b></span></div>
|
||||
<div class="sys-item"><span class="sys-label">Фото групп</span><span class="sys-value">${photos.group_photos?.count || 0} · ${photos.group_photos?.size || '0 B'}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Фото в записях</span><span class="sys-value">${photos.entry_photos?.count || 0} · ${photos.entry_photos?.size || '0 B'}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Всего</span><span class="sys-value"><b>${photos.total_count || 0}</b> · <b>${photos.total_size || '0 B'}</b></span></div>
|
||||
</div>
|
||||
<div class="sys-card">
|
||||
<div class="sys-title">Файлы</div>
|
||||
@@ -213,7 +213,7 @@ document.getElementById('restoreBtn').addEventListener('click', () => document.g
|
||||
document.getElementById('saveBtn').addEventListener('click', saveSettings);
|
||||
|
||||
(async () => {
|
||||
if (await checkAuth()) {
|
||||
if (await requireAdminPage()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
bindDirty();
|
||||
loadSettings();
|
||||
|
||||
+52
-38
@@ -17,47 +17,61 @@
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="linkModal">
|
||||
<div class="edit-modal">
|
||||
<div class="edit-modal" style="max-width:480px">
|
||||
<h3 id="linkModalTitle">Новая ссылка</h3>
|
||||
<div>
|
||||
<label>Название</label>
|
||||
<input type="text" id="linkName" placeholder="Например: СБ1400 05.09">
|
||||
</div>
|
||||
<div>
|
||||
<label>Группа</label>
|
||||
<select id="linkGroup"><option value="">— без группы —</option></select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Ученик (необязательно)</label>
|
||||
<input type="text" id="linkStudent" list="linkStudentList" placeholder="Все / введите имя..."><datalist id="linkStudentList"></datalist>
|
||||
</div>
|
||||
<div style="display:flex;gap:12px">
|
||||
<div style="flex:1">
|
||||
<label>С</label>
|
||||
<input type="date" id="linkDateFrom">
|
||||
<div class="settings-stack" style="gap:12px">
|
||||
<div class="settings-row">
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label>Название <span style="color:#ef4444">*</span></label>
|
||||
<input type="text" id="linkName" class="settings-input" required placeholder="Например: СБ1400 05.09">
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<label>По</label>
|
||||
<input type="date" id="linkDateTo">
|
||||
<div class="settings-row">
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label>Группа</label>
|
||||
<select id="linkGroup" class="settings-input"><option value="">— без группы —</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label>Ученик (необязательно)</label>
|
||||
<input type="text" id="linkStudent" class="settings-input" list="linkStudentList" placeholder="Все / введите имя..."><datalist id="linkStudentList"></datalist>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label>С</label>
|
||||
<input type="date" id="linkDateFrom" class="settings-input">
|
||||
</div>
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label>По</label>
|
||||
<input type="date" id="linkDateTo" class="settings-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label>Истекает через (дни)</label>
|
||||
<input type="number" id="linkExpiresDays" class="settings-input" min="1" max="365" value="7" placeholder="7">
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="linkAnonymizeNames">
|
||||
Показывать только инициалы учеников (анонимизировать)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label>Пароль для доступа (необязательно)</label>
|
||||
<input type="password" id="linkPassword" class="settings-input" placeholder="Оставьте пустым для открытого доступа">
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||
<button type="button" class="btn-primary ghost" id="linkCancelBtn">Отмена</button>
|
||||
<button type="button" class="btn-primary" id="linkSaveBtn">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label>Истекает через (дни)</label>
|
||||
<input type="number" id="linkExpiresDays" min="1" max="365" value="7" placeholder="7">
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
<input type="checkbox" id="linkAnonymizeNames">
|
||||
Показывать только инициалы учеников (анонимизировать)
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label>Пароль для доступа (необязательно)</label>
|
||||
<input type="password" id="linkPassword" placeholder="Оставьте пустым для открытого доступа">
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="cancel" type="button" id="linkCancelBtn">Отмена</button>
|
||||
<button class="save" type="button" id="linkSaveBtn">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+3
-2
@@ -23,9 +23,10 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
||||
<body>
|
||||
<div class="login">
|
||||
<h1>Админ-панель</h1>
|
||||
<input type="password" id="pwdInput" placeholder="Пароль" autofocus>
|
||||
<input type="text" id="userInput" placeholder="Логин" autocomplete="username" autofocus>
|
||||
<input type="password" id="pwdInput" placeholder="Пароль" autocomplete="current-password">
|
||||
<button type="button" id="loginBtn">Войти</button>
|
||||
<div class="err" id="loginErr">Неверный пароль</div>
|
||||
<div class="err" id="loginErr">Неверный логин или пароль</div>
|
||||
<div class="back"><a href="/">На главную</a></div>
|
||||
</div>
|
||||
<script src="js/login.js"></script>
|
||||
|
||||
+11
-11
@@ -15,7 +15,17 @@
|
||||
<p class="page-sub">Параметры формы и данные системы</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-stack">
|
||||
<div class="settings-stack settings-stack-grid">
|
||||
<div class="settings-card wide">
|
||||
<div class="card-head">
|
||||
<div class="ic">📊</div>
|
||||
<h3>Системная информация</h3>
|
||||
</div>
|
||||
<div id="systemInfo" class="sys-grid">
|
||||
<div class="sys-card loading">Загрузка…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="card-head">
|
||||
<div class="ic">🛡️</div>
|
||||
@@ -65,16 +75,6 @@
|
||||
</div>
|
||||
<div class="restore-status" id="restoreStatus"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="card-head">
|
||||
<div class="ic">📊</div>
|
||||
<h3>Системная информация</h3>
|
||||
</div>
|
||||
<div id="systemInfo" class="sys-grid">
|
||||
<div class="sys-card loading">Загрузка…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Пользователи — Админ-панель</title>
|
||||
<link rel="stylesheet" href="admin.css">
|
||||
</head>
|
||||
<body data-page="users">
|
||||
<div class="layout">
|
||||
<div class="sidebar" id="sidebar"></div>
|
||||
<div class="main">
|
||||
<div class="page-head">
|
||||
<h2>Пользователи</h2>
|
||||
<p class="page-sub">Управление доступом и ролями</p>
|
||||
</div>
|
||||
|
||||
<div class="actions-row" style="margin-bottom:16px">
|
||||
<button class="btn-primary" id="addUserBtn" type="button">
|
||||
<span style="font-size:1.2rem">+</span> Добавить пользователя
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="audit-wrap">
|
||||
<table class="audit-table" id="usersTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Логин</th>
|
||||
<th>Имя</th>
|
||||
<th>Роль</th>
|
||||
<th>Филиалы</th>
|
||||
<th>Статус</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<div class="empty" id="usersEmpty" style="display:none">Пользователи не найдены</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="userModal">
|
||||
<div class="edit-modal" style="max-width:480px">
|
||||
<h3 id="userModalTitle">Новый пользователь</h3>
|
||||
<div class="settings-stack" style="gap:12px;max-width:none">
|
||||
<div class="settings-field">
|
||||
<label>Логин <span style="color:#ef4444">*</span></label>
|
||||
<input type="text" id="uUsername" class="settings-input" placeholder="login" autocomplete="off">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Имя</label>
|
||||
<input type="text" id="uName" class="settings-input" placeholder="Имя Фамилия">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Роль</label>
|
||||
<select id="uRole" class="settings-input">
|
||||
<option value="admin">Администратор</option>
|
||||
<option value="tutor">Тутор</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-field" id="uBranchesField">
|
||||
<label>Филиалы (мультивыбор)</label>
|
||||
<select id="uBranches" class="settings-input" multiple size="4"></select>
|
||||
<span class="hint" style="font-size:.75rem">Тутор видит только свои филиалы. Выбор: Ctrl/Cmd+клик.</span>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Пароль <small id="pwdHint" style="font-weight:400;color:var(--muted)"></small></label>
|
||||
<input type="password" id="uPassword" class="settings-input" placeholder="••••••••">
|
||||
</div>
|
||||
<div class="settings-field" style="flex-direction:row;align-items:center;gap:8px">
|
||||
<input type="checkbox" id="uActive" checked>
|
||||
<label style="margin:0">Активен</label>
|
||||
</div>
|
||||
<div class="card-foot" style="justify-content:flex-end;border-top:none;padding-top:0">
|
||||
<button type="button" class="btn-primary ghost" id="uCancel">Отмена</button>
|
||||
<button type="button" class="btn-primary" id="uSave">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="admin.js"></script>
|
||||
<script src="js/users.js"></script>
|
||||
<style>
|
||||
.badge-admin,.badge-tutor{font-size:.7rem;font-weight:600;padding:2px 10px;border-radius:999px;white-space:nowrap}
|
||||
.badge-admin{color:#7c3aed;background:rgba(124,58,237,.12)}
|
||||
.badge-tutor{color:var(--accent);background:rgba(37,99,235,.1)}
|
||||
.status-ok{font-size:.72rem;font-weight:600;color:#16a34a;background:rgba(22,163,74,.12);border-radius:999px;padding:2px 10px;white-space:nowrap}
|
||||
.status-off{font-size:.72rem;font-weight:600;color:#ef4444;background:rgba(239,68,68,.12);border-radius:999px;padding:2px 10px;white-space:nowrap}
|
||||
.row-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.05rem;padding:4px 8px;border-radius:6px}
|
||||
.row-btn:hover{background:var(--bg);color:var(--accent)}
|
||||
.row-btn.del:hover{color:#ef4444;background:rgba(239,68,68,.1)}
|
||||
.user-branches{max-width:220px;color:var(--muted);font-size:.78rem}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user