master #1
@@ -23,6 +23,7 @@ function doLogout() {
|
|||||||
const NAV = [
|
const NAV = [
|
||||||
{ page: 'dashboard', label: 'Дашборд' },
|
{ page: 'dashboard', label: 'Дашборд' },
|
||||||
{ page: 'journal', label: 'Журнал' },
|
{ page: 'journal', label: 'Журнал' },
|
||||||
|
{ page: 'files', label: 'Файлы' },
|
||||||
{ page: 'links', label: 'Ссылки' },
|
{ page: 'links', label: 'Ссылки' },
|
||||||
{ page: 'students', label: 'Ученики' },
|
{ page: 'students', label: 'Ученики' },
|
||||||
{ page: 'groups', label: 'Группы' },
|
{ page: 'groups', label: 'Группы' },
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<!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">
|
||||||
|
<style>
|
||||||
|
.file-icon{width:80px;height:80px;border-radius:8px;background:var(--bg);border:1px solid var(--border);display:flex;align-items:center;justify-content:center;font-size:1.6rem;flex-shrink:0;color:var(--muted)}
|
||||||
|
.file-name{font-weight:600;font-size:.95rem}
|
||||||
|
.file-name a{color:var(--text);text-decoration:none}
|
||||||
|
.file-name a:hover{color:var(--accent)}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body data-page="files">
|
||||||
|
<div class="layout">
|
||||||
|
<div class="sidebar" id="sidebar"></div>
|
||||||
|
<div class="main">
|
||||||
|
<h2>Файлы</h2>
|
||||||
|
<div class="filters">
|
||||||
|
<div><label>Поиск</label><input type="text" id="searchInput" class="search-input" placeholder="Имя файла"></div>
|
||||||
|
<div><label>Студент</label><input type="text" id="studentFilter" list="studentFilterList" placeholder="Все / введите имя..."><datalist id="studentFilterList"></datalist></div>
|
||||||
|
<div><label>Группа</label><select id="groupFilter"><option value="">Все группы</option></select></div>
|
||||||
|
<div><label>С</label><input type="date" id="dateFrom"></div>
|
||||||
|
<div><label>По</label><input type="date" id="dateTo"></div>
|
||||||
|
</div>
|
||||||
|
<div class="grid" id="grid"><div class="empty">Загрузка...</div></div>
|
||||||
|
<div class="pager" id="pager"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="imgModal" onclick="this.classList.remove('open')">
|
||||||
|
<img id="imgModalSrc">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
<script src="admin.js"></script>
|
||||||
|
<script>
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
let page = 1;
|
||||||
|
let totalFiles = 0;
|
||||||
|
let currentFiles = [];
|
||||||
|
let groupsLoaded = false;
|
||||||
|
let studentsLoaded = false;
|
||||||
|
let searchTimer = null;
|
||||||
|
|
||||||
|
function isImageFile(name) {
|
||||||
|
return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanSize(b) {
|
||||||
|
if (!b) return '';
|
||||||
|
if (b < 1024) return b + ' Б';
|
||||||
|
if (b < 1048576) return (b / 1024).toFixed(1) + ' КБ';
|
||||||
|
return (b / 1048576).toFixed(1) + ' МБ';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileCardHTML(f) {
|
||||||
|
const url = `${API}/api/files/${f.token}`;
|
||||||
|
const thumb = isImageFile(f.name)
|
||||||
|
? `<img src="${url}" onclick="showImg('${url}')" alt="">`
|
||||||
|
: `<div class="file-icon">📄</div>`;
|
||||||
|
const target = isImageFile(f.name)
|
||||||
|
? `href="#" onclick="showImg('${url}');return false;"`
|
||||||
|
: `href="${url}" download`;
|
||||||
|
return `
|
||||||
|
<div class="card">
|
||||||
|
${thumb}
|
||||||
|
<div class="info">
|
||||||
|
<div class="file-name"><a ${target}>${esc(f.name)}</a></div>
|
||||||
|
<div class="group">${esc(f.student_name)} · ${esc(f.group_name)}</div>
|
||||||
|
<div class="time">${new Date(f.created_at).toLocaleString('ru')}${f.size ? ' · ' + humanSize(f.size) : ''}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGrid() {
|
||||||
|
const grid = document.getElementById('grid');
|
||||||
|
if (!currentFiles.length) {
|
||||||
|
grid.className = 'grid';
|
||||||
|
grid.innerHTML = '<div class="empty">Файлов не найдено</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
grid.className = 'grid';
|
||||||
|
grid.innerHTML = currentFiles.map(fileCardHTML).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGroupOptions() {
|
||||||
|
if (groupsLoaded) return;
|
||||||
|
const sel = document.getElementById('groupFilter');
|
||||||
|
while (sel.options.length > 1) sel.remove(1);
|
||||||
|
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||||||
|
const groups = await res.json();
|
||||||
|
groups.forEach(g => {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = g.id; o.textContent = g.name;
|
||||||
|
sel.appendChild(o);
|
||||||
|
});
|
||||||
|
groupsLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStudentOptions() {
|
||||||
|
if (studentsLoaded) return;
|
||||||
|
const dl = document.getElementById('studentFilterList');
|
||||||
|
const res = await fetch(`${API}/api/students/names`, { headers: hdr() });
|
||||||
|
const names = await res.json();
|
||||||
|
dl.innerHTML = names.map(n => `<option value="${esc(n)}">`).join('');
|
||||||
|
studentsLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFiles() {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
const q = document.getElementById('searchInput').value;
|
||||||
|
const sn = document.getElementById('studentFilter').value;
|
||||||
|
const gid = document.getElementById('groupFilter').value;
|
||||||
|
const df = document.getElementById('dateFrom').value;
|
||||||
|
const dt = document.getElementById('dateTo').value;
|
||||||
|
if (q) p.set('search', q);
|
||||||
|
if (sn) p.set('student_name', sn);
|
||||||
|
if (gid) p.set('group_id', gid);
|
||||||
|
if (df) p.set('date_from', df);
|
||||||
|
if (dt) p.set('date_to', dt);
|
||||||
|
p.set('limit', PAGE_SIZE);
|
||||||
|
p.set('offset', (page - 1) * PAGE_SIZE);
|
||||||
|
const res = await fetch(`${API}/api/files?${p}`, { headers: hdr() });
|
||||||
|
const data = await res.json();
|
||||||
|
currentFiles = data.files;
|
||||||
|
totalFiles = data.total;
|
||||||
|
renderGrid();
|
||||||
|
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalFiles / PAGE_SIZE)), goPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goPage(p) {
|
||||||
|
page = p;
|
||||||
|
loadFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('studentFilter').addEventListener('change', () => { page = 1; loadFiles(); });
|
||||||
|
document.getElementById('groupFilter').addEventListener('change', () => { page = 1; loadFiles(); });
|
||||||
|
document.getElementById('dateFrom').addEventListener('change', () => { page = 1; loadFiles(); });
|
||||||
|
document.getElementById('dateTo').addEventListener('change', () => { page = 1; loadFiles(); });
|
||||||
|
document.getElementById('searchInput').addEventListener('input', () => {
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(() => { page = 1; loadFiles(); }, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
if (await checkAuth()) {
|
||||||
|
buildSidebar(document.body.dataset.page);
|
||||||
|
loadGroupOptions();
|
||||||
|
loadStudentOptions();
|
||||||
|
loadFiles();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -514,6 +514,40 @@ function isImageName(name) {
|
|||||||
return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
|
return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.get('/api/files', requireAdmin, async (req, res) => {
|
||||||
|
const { search, student_name, group_id, date_from, date_to, limit, offset } = req.query;
|
||||||
|
const conditions = [];
|
||||||
|
const params = [];
|
||||||
|
if (search) { params.push(`%${search}%`); conditions.push(`pf.name ILIKE $${params.length}`); }
|
||||||
|
if (student_name) { params.push(student_name); conditions.push(`e.student_name = $${params.length}`); }
|
||||||
|
if (group_id) { params.push(group_id); conditions.push(`e.group_id = $${params.length}`); }
|
||||||
|
if (date_from) { params.push(date_from); conditions.push(`e.created_at >= $${params.length}::date`); }
|
||||||
|
if (date_to) { params.push(date_to); conditions.push(`e.created_at < ($${params.length}::date + interval '1 day')`); }
|
||||||
|
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '';
|
||||||
|
const { rows: crows } = await pool.query(
|
||||||
|
`SELECT count(*)::int AS n FROM project_files pf JOIN entries e ON e.id = pf.entry_id${where}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
const total = crows[0].n;
|
||||||
|
let q = `SELECT pf.id, pf.token, pf.name, pf.path, e.student_name, e.created_at, g.name AS group_name
|
||||||
|
FROM project_files pf
|
||||||
|
JOIN entries e ON e.id = pf.entry_id
|
||||||
|
JOIN groups g ON g.id = e.group_id${where}
|
||||||
|
ORDER BY pf.id DESC`;
|
||||||
|
const qparams = params.slice();
|
||||||
|
const lim = parseInt(limit, 10);
|
||||||
|
if (lim > 0) { qparams.push(lim); q += ` LIMIT $${qparams.length}`; }
|
||||||
|
const off = parseInt(offset, 10);
|
||||||
|
if (off > 0) { qparams.push(off); q += ` OFFSET $${qparams.length}`; }
|
||||||
|
const { rows } = await pool.query(q, qparams);
|
||||||
|
const files = rows.map(r => {
|
||||||
|
let size = 0;
|
||||||
|
try { size = fs.statSync(path.join(__dirname, r.path)).size; } catch {}
|
||||||
|
return { id: r.id, token: r.token, name: r.name, student_name: r.student_name, group_name: r.group_name, created_at: r.created_at, size };
|
||||||
|
});
|
||||||
|
res.json({ files, total });
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/files/:token', async (req, res) => {
|
app.get('/api/files/:token', async (req, res) => {
|
||||||
const { rows } = await pool.query('SELECT path, name FROM project_files WHERE token = $1', [req.params.token]);
|
const { rows } = await pool.query('SELECT path, name FROM project_files WHERE token = $1', [req.params.token]);
|
||||||
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
||||||
|
|||||||
Reference in New Issue
Block a user