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
+1
View File
@@ -1,2 +1,3 @@
ADMIN_USERNAME=admin
ADMIN_PASSWORD=сложный-пароль-админки
DB_PASSWORD=случайная-длинная-строка
+1
View File
@@ -70,6 +70,7 @@ CREATE TABLE IF NOT EXISTS group_photos (
photo_path VARCHAR(255) NOT NULL,
caption TEXT,
taken_at DATE,
sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now()
);
+2
View File
@@ -102,4 +102,6 @@ CREATE TABLE IF NOT EXISTS group_photos (
created_at TIMESTAMPTZ DEFAULT now()
);
ALTER TABLE group_photos ADD COLUMN IF NOT EXISTS sort_order INT DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_group_photos_group_id ON group_photos(group_id);
+32
View File
@@ -28,6 +28,7 @@ services:
environment:
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/whereldo
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
NODE_ENV: production
TZ: Europe/Moscow
depends_on:
@@ -58,5 +59,36 @@ services:
depends_on:
- app
text-corrector:
image: ghcr.io/ggml-org/llama.cpp:server
container_name: text-corrector
restart: unless-stopped
volumes:
- ./models:/models
ports:
- "8080:8080"
command:
- -m
- /models/qwen2.5-1.5b-instruct-q4_k_m.gguf
- --host
- 0.0.0.0
- --port
- "8080"
- -c
- "4096"
- -n
- "256"
- -t
- "8"
- --temp
- "0.1"
volumes:
pgdata:
Binary file not shown.
+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}
+45 -17
View File
@@ -1,26 +1,46 @@
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 = [
function buildSidebar(active) {
const el = document.getElementById('sidebar');
if (!el) return;
const base = [
{ page: 'dashboard', label: 'Дашборд' },
{ page: 'journal', label: 'Журнал' },
{ page: 'trash', label: 'Корзина' },
@@ -28,17 +48,24 @@ const NAV = [
{ 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 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();
+37 -23
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 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>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Группа</label>
<select id="linkGroup"><option value="">— без группы —</option></select>
<select id="linkGroup" class="settings-input"><option value="">— без группы —</option></select>
</div>
<div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Ученик (необязательно)</label>
<input type="text" id="linkStudent" list="linkStudentList" placeholder="Все / введите имя..."><datalist id="linkStudentList"></datalist>
<input type="text" id="linkStudent" class="settings-input" list="linkStudentList" placeholder="Все / введите имя..."><datalist id="linkStudentList"></datalist>
</div>
<div style="display:flex;gap:12px">
<div style="flex:1">
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>С</label>
<input type="date" id="linkDateFrom">
<input type="date" id="linkDateFrom" class="settings-input">
</div>
<div style="flex:1">
<div class="settings-field" style="flex:1">
<label>По</label>
<input type="date" id="linkDateTo">
<input type="date" id="linkDateTo" class="settings-input">
</div>
</div>
<div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Истекает через (дни)</label>
<input type="number" id="linkExpiresDays" min="1" max="365" value="7" placeholder="7">
<input type="number" id="linkExpiresDays" class="settings-input" min="1" max="365" value="7" placeholder="7">
</div>
<div>
<label>
</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>
<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 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>
</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>
+131 -60
View File
@@ -68,6 +68,54 @@ app.use(express.static(path.join(__dirname, 'public')));
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function getDiskInfo() {
const totalDisk = fs.statfsSync ? fs.statfsSync(__dirname) : null;
if (totalDisk) {
const blockSize = totalDisk.bsize || 4096;
const total = totalDisk.blocks * blockSize;
const free = totalDisk.bfree * blockSize;
const used = total - free;
return {
total: formatBytes(total),
total_bytes: total,
used: formatBytes(used),
used_bytes: used,
free: formatBytes(free),
free_bytes: free,
used_pct: Math.round((used / total) * 100),
};
}
try {
const { execSync } = require('child_process');
const out = execSync('df -B1 .', { encoding: 'utf8' });
const lines = out.trim().split('\n');
if (lines.length > 1) {
const parts = lines[1].split(/\s+/);
const total = parseInt(parts[1], 10);
const used = parseInt(parts[2], 10);
const free = parseInt(parts[3], 10);
return {
total: formatBytes(total),
total_bytes: total,
used: formatBytes(used),
used_bytes: used,
free: formatBytes(free),
free_bytes: free,
used_pct: Math.round((used / total) * 100),
};
}
} catch {}
return null;
}
function safeUser(u) {
return {
id: u.id,
@@ -1189,7 +1237,7 @@ app.get('/api/share/:token', fileLimiter, async (req, res) => {
if (l.group_id) {
const pRes = await pool.query(
`SELECT id, photo_path, caption, taken_at, created_at FROM group_photos
WHERE group_id = $1 ORDER BY taken_at DESC NULLS LAST, created_at DESC LIMIT 12`,
WHERE group_id = $1 ORDER BY sort_order ASC, taken_at DESC NULLS LAST, created_at DESC LIMIT 12`,
[l.group_id]
);
photos = pRes.rows.map(r => ({ id: r.id, photo_path: r.photo_path, caption: r.caption, taken_at: r.taken_at, created_at: r.created_at }));
@@ -1253,13 +1301,14 @@ app.get('/s/:token', (req, res) => {
app.get('/api/groups', apiLimiter, optionalAuth, async (req, res) => {
const bw = req.user ? branchWhere(req.user, 'g') : { where: '', params: [] };
const { rows } = await pool.query(
`SELECT g.*, gp.photo_path AS cover_path, b.name AS branch_name
FROM groups g
LEFT JOIN LATERAL (
SELECT photo_path FROM group_photos
`SELECT g.*,
COALESCE(g.cover_path,
(SELECT photo_path FROM group_photos
WHERE group_id = g.id
ORDER BY taken_at DESC NULLS LAST, created_at DESC LIMIT 1
) gp ON true
ORDER BY sort_order ASC, taken_at DESC NULLS LAST, created_at DESC
LIMIT 1)) AS cover_path,
b.name AS branch_name
FROM groups g
LEFT JOIN branches b ON b.id = g.branch_id
WHERE 1=1${bw.where}
ORDER BY g.id`,
@@ -1428,7 +1477,7 @@ app.get('/api/groups/:id/photos', requireAuth, async (req, res) => {
);
const total = crows[0].n;
let q = `SELECT * FROM group_photos WHERE group_id = $1
ORDER BY taken_at DESC NULLS LAST, created_at DESC`;
ORDER BY sort_order ASC, taken_at DESC NULLS LAST, created_at DESC`;
const qparams = [req.params.id];
const lim = parseInt(limit, 10);
if (lim > 0) { qparams.push(lim); q += ` LIMIT $${qparams.length}`; }
@@ -1464,8 +1513,10 @@ app.post('/api/groups/:id/photos', requireAuth, (req, res, next) => {
try {
await convertPhoto(req.file);
const { rows } = await pool.query(
`INSERT INTO group_photos (group_id, photo_path, caption, taken_at)
VALUES ($1, $2, $3, $4) RETURNING *`,
`INSERT INTO group_photos (group_id, photo_path, caption, taken_at, sort_order)
VALUES ($1, $2, $3, $4,
COALESCE((SELECT MIN(sort_order) - 1 FROM group_photos WHERE group_id = $1), 0))
RETURNING *`,
[req.params.id, `/uploads/${req.file.filename}`, caption?.trim() || null, taken_at || null]
);
await logAudit(req, 'group.photo.create', { group_id: req.params.id, photo_path: rows[0].photo_path });
@@ -1477,6 +1528,49 @@ app.post('/api/groups/:id/photos', requireAuth, (req, res, next) => {
}
});
app.put('/api/groups/:id/photos/reorder', requireAuth, async (req, res) => {
if (req.user.role !== 'admin' && !(await groupBelongsToBranches(req.user, req.params.id))) {
return res.status(403).json({ error: 'Нет доступа к этой группе' });
}
const { order } = req.body;
if (!Array.isArray(order) || order.some(id => !Number.isInteger(Number(id)))) {
return res.status(400).json({ error: 'Некорректный порядок фото' });
}
const ids = order.map(id => Number(id));
if (new Set(ids).size !== ids.length) {
return res.status(400).json({ error: 'Порядок фото содержит дубликаты' });
}
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows: owned } = await client.query(
'SELECT id FROM group_photos WHERE group_id = $1 ORDER BY sort_order ASC, taken_at DESC NULLS LAST, created_at DESC',
[req.params.id]
);
const ownedIds = owned.map(r => r.id);
if (ids.some(id => !ownedIds.includes(id))) {
throw { http: 404, message: 'Фото не найдено' };
}
const rest = ownedIds.filter(id => !ids.includes(id));
const allIds = [...ids, ...rest];
for (let i = 0; i < allIds.length; i++) {
await client.query(
'UPDATE group_photos SET sort_order = $1 WHERE id = $2',
[i + 1, allIds[i]]
);
}
await client.query('COMMIT');
await logAudit(req, 'group.photo.reorder', { group_id: req.params.id, order: ids });
res.json({ ok: true });
} catch (e) {
await client.query('ROLLBACK');
if (e.http) return res.status(e.http).json({ error: e.message });
throw e;
} finally {
client.release();
}
});
app.put('/api/groups/:id/photos/:photoId', requireAuth, async (req, res) => {
if (req.user.role !== 'admin' && !(await groupBelongsToBranches(req.user, req.params.id))) {
return res.status(403).json({ error: 'Нет доступа к этой группе' });
@@ -1504,6 +1598,7 @@ app.delete('/api/groups/:id/photos/:photoId', requireAuth, async (req, res) => {
);
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
safeUnlink(rows[0].photo_path);
await pool.query('UPDATE groups SET cover_path = NULL WHERE id = $1 AND cover_path = $2', [req.params.id, rows[0].photo_path]);
await pool.query('DELETE FROM group_photos WHERE id = $1', [req.params.photoId]);
await logAudit(req, 'group.photo.delete', { group_id: req.params.id, photo_id: req.params.photoId });
res.json({ ok: true });
@@ -1961,6 +2056,24 @@ app.get('/api/system-info', requireAdmin, async (_, res) => {
WHERE photo_path IS NOT NULL AND deleted_at IS NULL
`);
function sumPhotoSizes(paths) {
let bytes = 0;
for (const p of paths) {
if (!p) continue;
const fp = path.join(__dirname, p);
try {
const st = fs.statSync(fp);
if (st.isFile()) bytes += st.size;
} catch {}
}
return bytes;
}
const groupPhotoSizes = await pool.query('SELECT photo_path FROM group_photos');
const entryPhotoSizes = await pool.query('SELECT photo_path FROM entries WHERE photo_path IS NOT NULL AND deleted_at IS NULL');
const groupPhotoBytes = sumPhotoSizes(groupPhotoSizes.rows.map(r => r.photo_path));
const entryPhotoBytes = sumPhotoSizes(entryPhotoSizes.rows.map(r => r.photo_path));
const uploadsDir = path.join(__dirname, 'uploads');
let uploadsSize = 0;
let uploadsCount = 0;
@@ -1974,52 +2087,7 @@ app.get('/api/system-info', requireAdmin, async (_, res) => {
}
}
const totalDisk = fs.statfsSync ? fs.statfsSync(__dirname) : null;
let diskInfo = null;
if (totalDisk) {
const blockSize = totalDisk.bsize || 4096;
const total = totalDisk.blocks * blockSize;
const free = totalDisk.bfree * blockSize;
const used = total - free;
diskInfo = {
total: formatBytes(total),
total_bytes: total,
used: formatBytes(used),
used_bytes: used,
free: formatBytes(free),
free_bytes: free,
used_pct: Math.round((used / total) * 100),
};
} else {
try {
const { execSync } = require('child_process');
const out = execSync('df -B1 .', { encoding: 'utf8' });
const lines = out.trim().split('\n');
if (lines.length > 1) {
const parts = lines[1].split(/\s+/);
const total = parseInt(parts[1], 10);
const used = parseInt(parts[2], 10);
const free = parseInt(parts[3], 10);
diskInfo = {
total: formatBytes(total),
total_bytes: total,
used: formatBytes(used),
used_bytes: used,
free: formatBytes(free),
free_bytes: free,
used_pct: Math.round((used / total) * 100),
};
}
} catch {}
}
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
const diskInfo = getDiskInfo();
res.json({
database: {
@@ -2032,9 +2100,11 @@ app.get('/api/system-info', requireAdmin, async (_, res) => {
})),
},
photos: {
group_photos: { count: photoStats.rows[0].count || 0 },
entry_photos: { count: entryPhotoStats.rows[0].count || 0 },
group_photos: { count: photoStats.rows[0].count || 0, size: formatBytes(groupPhotoBytes), size_bytes: groupPhotoBytes },
entry_photos: { count: entryPhotoStats.rows[0].count || 0, size: formatBytes(entryPhotoBytes), size_bytes: entryPhotoBytes },
total_count: (photoStats.rows[0].count || 0) + (entryPhotoStats.rows[0].count || 0),
total_size: formatBytes(groupPhotoBytes + entryPhotoBytes),
total_size_bytes: groupPhotoBytes + entryPhotoBytes,
},
files: {
project_files: { count: fileStats.rows[0].count || 0 },
@@ -2104,12 +2174,12 @@ app.get('/api/dashboard', requireAuth, async (req, res) => {
pool.query(
`SELECT gp.id, gp.photo_path, gp.caption, gp.taken_at, g.name AS group_name
FROM group_photos gp JOIN groups g ON g.id = gp.group_id${whereGroup}
ORDER BY gp.taken_at DESC NULLS LAST, gp.created_at DESC LIMIT 8`,
ORDER BY gp.sort_order ASC, gp.taken_at DESC NULLS LAST, gp.created_at DESC LIMIT 8`,
whereGroup ? whereGroupParams : []
),
pool.query(
`SELECT gp.photo_path, gp.taken_at FROM group_photos gp JOIN groups g ON g.id = gp.group_id${whereGroup}
ORDER BY gp.taken_at DESC NULLS LAST, gp.created_at DESC LIMIT 1`,
ORDER BY gp.sort_order ASC, gp.taken_at DESC NULLS LAST, gp.created_at DESC LIMIT 1`,
whereGroup ? whereGroupParams : []
),
]);
@@ -2128,6 +2198,7 @@ app.get('/api/dashboard', requireAuth, async (req, res) => {
top_students: top.rows,
photos: photos.rows,
latest_photo_taken: (latestPhotos.rows[0] || {}).taken_at || null,
disk: getDiskInfo(),
});
});