Update project files

This commit is contained in:
dev
2026-09-10 11:26:51 +03:00
parent 7ccc9199e0
commit 3850fe35e4
19 changed files with 511 additions and 155 deletions
+12 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+1 -1
View File
@@ -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
View File
@@ -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);
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+99
View File
@@ -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>