кеш буст
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,3 +3,6 @@ data/
|
||||
uploads/
|
||||
server.log
|
||||
*.pdf
|
||||
*.txt
|
||||
package-lock.json
|
||||
*.zip
|
||||
1559
package-lock.json
generated
1559
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.17",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"express": "^4.22.2",
|
||||
"multer": "^2.2.0",
|
||||
|
||||
7365
pdf_text.txt
7365
pdf_text.txt
File diff suppressed because it is too large
Load Diff
321
public/app.js
321
public/app.js
@@ -34,6 +34,16 @@ document.getElementById('uploadForm').addEventListener('submit', async (e) => {
|
||||
uploadStatus.textContent = 'Загрузка...';
|
||||
uploadStatus.className = '';
|
||||
|
||||
const progressEl = document.getElementById('uploadProgress');
|
||||
const progressFill = progressEl.querySelector('.progress-fill');
|
||||
const progressText = progressEl.querySelector('.progress-text');
|
||||
progressEl.classList.remove('hidden');
|
||||
const startTime = Date.now();
|
||||
let timer = setInterval(() => {
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
progressText.textContent = `Обработка... ${elapsed}с`;
|
||||
}, 1000);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('pdf', selectedFile);
|
||||
|
||||
@@ -43,6 +53,17 @@ document.getElementById('uploadForm').addEventListener('submit', async (e) => {
|
||||
if (data.error) {
|
||||
uploadStatus.textContent = 'Ошибка: ' + data.error;
|
||||
uploadStatus.className = 'error';
|
||||
clearInterval(timer);
|
||||
progressEl.classList.add('hidden');
|
||||
resetUpload();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.taskId) {
|
||||
clearInterval(timer);
|
||||
progressFill.classList.add('determinate');
|
||||
await pollTask(data.taskId, progressFill, progressText, startTime);
|
||||
progressFill.classList.remove('determinate');
|
||||
} else {
|
||||
uploadStatus.textContent = data.message;
|
||||
uploadStatus.className = 'success';
|
||||
@@ -56,11 +77,73 @@ document.getElementById('uploadForm').addEventListener('submit', async (e) => {
|
||||
uploadStatus.className = 'error';
|
||||
}
|
||||
|
||||
clearInterval(timer);
|
||||
progressEl.classList.add('hidden');
|
||||
resetUpload();
|
||||
});
|
||||
|
||||
let _pollTimer = null;
|
||||
|
||||
async function pollTask(taskId, progressFill, progressText, startTime) {
|
||||
const updateProgress = (task) => {
|
||||
const pct = task.total ? Math.round(task.done / task.total * 100) : 0;
|
||||
progressFill.style.width = pct + '%';
|
||||
let txt = task.done + '/' + task.total + ' файлов · ' + task.elapsed + 'с';
|
||||
if (task.lastFile) {
|
||||
txt = '[Загружен] ' + task.lastFile + ' → ' + (task.lastResult || '') + ' · ' + task.elapsed + 'с';
|
||||
}
|
||||
progressText.textContent = txt;
|
||||
};
|
||||
|
||||
while (true) {
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
||||
const res = await fetch('/api/task/' + taskId, { signal: controller.signal });
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
if (text.startsWith('<')) throw new Error('Сервер временно недоступен');
|
||||
throw new Error('HTTP ' + res.status);
|
||||
}
|
||||
|
||||
const task = await res.json();
|
||||
|
||||
updateProgress(task);
|
||||
|
||||
if (task.status === 'done' || task.status === 'partial') {
|
||||
progressFill.style.width = '100%';
|
||||
uploadStatus.textContent = task.result.message;
|
||||
uploadStatus.className = 'success';
|
||||
statusEl.textContent = 'Данные загружены';
|
||||
statusEl.classList.add('loaded');
|
||||
loadFilters();
|
||||
loadAll();
|
||||
return;
|
||||
}
|
||||
if (task.status === 'error') {
|
||||
uploadStatus.textContent = 'Ошибка: ' + (task.error || '');
|
||||
uploadStatus.className = 'error';
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') {
|
||||
progressText.textContent = 'Таймаут запроса, повтор...';
|
||||
} else {
|
||||
progressText.textContent = 'Ожидание сервера... ' + err.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resetUpload() {
|
||||
selectedFile = null;
|
||||
fileInput.value = '';
|
||||
uploadBtn.disabled = true;
|
||||
dropZone.querySelector('.drop-text').textContent = 'Перетащите PDF сюда или кликните для выбора';
|
||||
});
|
||||
dropZone.querySelector('.drop-text').textContent = 'Перетащите PDF или ZIP сюда или кликните для выбора';
|
||||
}
|
||||
|
||||
document.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
@@ -76,11 +159,15 @@ function getFilterParams() {
|
||||
const student = document.getElementById('gfStudent').value.trim();
|
||||
const subject = document.getElementById('gfSubject').value.trim();
|
||||
const teacher = document.getElementById('gfTeacher').value.trim();
|
||||
const classVal = document.getElementById('gfClass').value.trim();
|
||||
const parallel = document.getElementById('gfParallel').value.trim();
|
||||
const from = document.getElementById('gfDateFrom').value;
|
||||
const to = document.getElementById('gfDateTo').value;
|
||||
if (student) params.student = student;
|
||||
if (subject) params.subject = subject;
|
||||
if (teacher) params.teacher = teacher;
|
||||
if (classVal) params.class = classVal;
|
||||
if (parallel) params.parallel = parallel;
|
||||
if (from) params.date_from = from.slice(5);
|
||||
if (to) params.date_to = to.slice(5);
|
||||
return params;
|
||||
@@ -111,6 +198,7 @@ async function loadAll() {
|
||||
renderClassesTable(stats);
|
||||
renderDailyTable(daily);
|
||||
loadPivot();
|
||||
loadControl();
|
||||
}
|
||||
|
||||
async function loadFilters() {
|
||||
@@ -126,6 +214,8 @@ async function loadFilters() {
|
||||
fill('dlStudents', data.students);
|
||||
fill('dlSubjects', data.subjects);
|
||||
fill('dlTeachers', data.teachers);
|
||||
fill('dlClasses', data.classes);
|
||||
fill('dlParallels', data.parallels);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
@@ -301,10 +391,14 @@ window.dailyNav = async function(page) {
|
||||
};
|
||||
|
||||
document.getElementById('gfApply').addEventListener('click', () => loadAll());
|
||||
document.getElementById('gfParallel').addEventListener('change', () => loadAll());
|
||||
document.getElementById('gfParallel').addEventListener('keydown', (e) => { if (e.key === 'Enter') loadAll(); });
|
||||
document.getElementById('gfReset').addEventListener('click', () => {
|
||||
document.getElementById('gfStudent').value = '';
|
||||
document.getElementById('gfSubject').value = '';
|
||||
document.getElementById('gfTeacher').value = '';
|
||||
document.getElementById('gfClass').value = '';
|
||||
document.getElementById('gfParallel').value = '';
|
||||
document.getElementById('gfDateFrom').value = '';
|
||||
document.getElementById('gfDateTo').value = '';
|
||||
loadAll();
|
||||
@@ -401,13 +495,17 @@ async function loadPivot() {
|
||||
params.period = pivotMode;
|
||||
const qs = buildQuery(params);
|
||||
|
||||
document.getElementById('pivotInfo').textContent = 'Загрузка...';
|
||||
const infoEl = document.getElementById('pivotInfo');
|
||||
const start = Date.now();
|
||||
const timer = setInterval(() => { infoEl.textContent = 'Загрузка... ' + Math.floor((Date.now() - start) / 1000) + 'с'; }, 500);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/pivot?' + qs);
|
||||
clearInterval(timer);
|
||||
const data = await res.json();
|
||||
if (data.error) {
|
||||
document.getElementById('pivotTable').innerHTML = '<tr><td class="empty-state">' + data.error + '</td></tr>';
|
||||
document.querySelector('#pivotTable tbody').innerHTML = '<tr><td class="empty-state">' + data.error + '</td></tr>';
|
||||
document.getElementById('pivotInfo').textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -491,5 +589,220 @@ window.toggleGroup = function(groupId) {
|
||||
}
|
||||
};
|
||||
|
||||
let controlMode = 'homework';
|
||||
|
||||
document.querySelectorAll('.ctrl-tab').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.ctrl-tab').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
controlMode = btn.dataset.ctrl;
|
||||
document.getElementById('ctrlFiltersHW').style.display = controlMode === 'homework' ? '' : 'none';
|
||||
document.getElementById('ctrlFiltersGrades').style.display = controlMode === 'grades' ? '' : 'none';
|
||||
document.getElementById('ctrlFiltersDvoiki').style.display = controlMode === 'dvoiki' ? '' : 'none';
|
||||
document.getElementById('ctrlTableWrap').style.display = controlMode === 'dvoiki' ? 'none' : '';
|
||||
document.getElementById('dvoikiCards').style.display = controlMode === 'dvoiki' ? '' : 'none';
|
||||
loadControl();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('ctrlNoTopic').addEventListener('change', loadControl);
|
||||
document.getElementById('ctrlNoHomework').addEventListener('change', loadControl);
|
||||
document.getElementById('ctrlMinGrades').addEventListener('change', loadControl);
|
||||
|
||||
async function loadControl() {
|
||||
const params = getFilterParams();
|
||||
|
||||
if (controlMode === 'dvoiki') {
|
||||
const qs = buildQuery(params);
|
||||
const container = document.getElementById('dvoikiCards');
|
||||
container.innerHTML = '<div class="empty-state"><span class="spinner"></span> <span id="ctrlLoadText">Загрузка...</span></div>';
|
||||
const loadText = document.getElementById('ctrlLoadText');
|
||||
const start = Date.now();
|
||||
const timer = setInterval(() => { if (loadText) loadText.textContent = 'Загрузка... ' + Math.floor((Date.now() - start) / 1000) + 'с'; }, 500);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/dvoiki?' + qs);
|
||||
clearInterval(timer);
|
||||
const data = await res.json();
|
||||
if (data.error) return;
|
||||
|
||||
const allRows = data.rows || [];
|
||||
const openCount = allRows.filter(r => !r.resolved).length;
|
||||
const closedCount = allRows.filter(r => r.resolved).length;
|
||||
const unfixedCount = allRows.filter(r => !r.resolved && !r.objective).length;
|
||||
const objectiveCount = allRows.filter(r => !r.resolved && r.objective).length;
|
||||
|
||||
const teacherStats = {};
|
||||
for (const r of allRows) {
|
||||
if (!r.resolved) {
|
||||
const t = r.teacher || '—';
|
||||
if (!teacherStats[t]) teacherStats[t] = { open: 0, closed: 0, total: 0 };
|
||||
teacherStats[t].total++;
|
||||
teacherStats[t].open++;
|
||||
}
|
||||
}
|
||||
const teacherList = Object.entries(teacherStats)
|
||||
.map(([name, s]) => ({ name, open: s.open, closed: s.closed, total: s.total }))
|
||||
.sort((a, b) => b.open - a.open || a.name.localeCompare(b.name));
|
||||
|
||||
renderDvoikiPills(allRows.length, openCount, unfixedCount, objectiveCount, closedCount, teacherList);
|
||||
|
||||
let rows = allRows;
|
||||
if (window._dvoikiFilter === 'unfixed') rows = rows.filter(r => !r.resolved && !r.objective);
|
||||
else if (window._dvoikiFilter === 'objective') rows = rows.filter(r => !r.resolved && r.objective);
|
||||
else if (window._dvoikiFilter === 'closed') rows = rows.filter(r => r.resolved);
|
||||
if (window._dvoikiTeacher && window._dvoikiTeacher !== 'all') {
|
||||
rows = rows.filter(r => (r.teacher || '—') === window._dvoikiTeacher);
|
||||
}
|
||||
|
||||
renderDvoikiCards(rows);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (controlMode === 'homework') {
|
||||
if (document.getElementById('ctrlNoTopic').checked) params.no_topic = 'true';
|
||||
if (document.getElementById('ctrlNoHomework').checked) params.no_homework = 'true';
|
||||
} else {
|
||||
params.low_grades = 'true';
|
||||
params.min_grades = document.getElementById('ctrlMinGrades').value || '3';
|
||||
}
|
||||
|
||||
const qs = buildQuery(params);
|
||||
|
||||
const thead = document.querySelector('#controlTable thead');
|
||||
thead.innerHTML = '<tr><th>Дата</th><th>Предмет</th><th>Учитель</th><th>Тема</th><th>ДЗ</th><th>Всего</th><th>Числ.</th><th>Симв.</th><th>Оценки</th></tr>';
|
||||
document.querySelector('#controlTable tbody').innerHTML = '<tr><td colspan="9" class="empty-state"><span class="spinner"></span> <span id="ctrlLoadText">Загрузка...</span></td></tr>';
|
||||
const loadText = document.getElementById('ctrlLoadText');
|
||||
const start = Date.now();
|
||||
const timer = setInterval(() => { if (loadText) loadText.textContent = 'Загрузка... ' + Math.floor((Date.now() - start) / 1000) + 'с'; }, 500);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/lessons?' + qs);
|
||||
clearInterval(timer);
|
||||
const data = await res.json();
|
||||
if (data.error) return;
|
||||
|
||||
const tbody = document.querySelector('#controlTable tbody');
|
||||
if (!data.rows || !data.rows.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="9" class="empty-state">Нет данных</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
const minNumeric = data.min_grades || 3;
|
||||
|
||||
tbody.innerHTML = data.rows.map(r => {
|
||||
let topicCls = '';
|
||||
let hwCls = '';
|
||||
let rowCls = '';
|
||||
if (controlMode === 'homework') {
|
||||
if (!r.topic) topicCls = 'missing';
|
||||
if (!r.homework) hwCls = 'missing';
|
||||
} else {
|
||||
if (r.grades_numeric < minNumeric) rowCls = 'red-row';
|
||||
}
|
||||
return `<tr class="${rowCls}">
|
||||
<td>${r.lesson_date}</td>
|
||||
<td>${r.subject}</td>
|
||||
<td>${r.teacher}</td>
|
||||
<td class="${topicCls}">${r.topic || '—'}</td>
|
||||
<td class="${hwCls}">${r.homework || '—'}</td>
|
||||
<td class="num">${r.grades_total}</td>
|
||||
<td class="num">${r.grades_numeric}</td>
|
||||
<td class="num">${r.grades_symbol}</td>
|
||||
<td class="grades-cell">${r.grades_list || ''}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
let _dvoikiFilter = 'all';
|
||||
|
||||
function renderDvoikiPills(total, open, unfixed, objective, closed, teacherList) {
|
||||
window._dvoikiFilter = window._dvoikiFilter || 'unfixed';
|
||||
window._dvoikiTeacher = window._dvoikiTeacher || 'all';
|
||||
|
||||
const pills = [
|
||||
{ key: 'all', label: 'Все', count: total, active: window._dvoikiFilter === 'all' },
|
||||
{ key: 'unfixed', label: '⚠️ Не исправили', count: unfixed, active: window._dvoikiFilter === 'unfixed' },
|
||||
{ key: 'objective', label: '🚫 Не могли исправить', count: objective, active: window._dvoikiFilter === 'objective' },
|
||||
{ key: 'closed', label: '✅ Закрыто', count: closed, active: window._dvoikiFilter === 'closed' }
|
||||
];
|
||||
|
||||
let html = '<div class="dvoiki-pills">' +
|
||||
pills.map(p =>
|
||||
`<button class="pill ${p.active ? 'active' : ''}" data-filter="${p.key}">${p.label} (${p.count})</button>`
|
||||
).join('') +
|
||||
'</div>';
|
||||
|
||||
html += '<div class="dvoiki-pills" style="margin-top:6px">' +
|
||||
`<button class="pill ${window._dvoikiTeacher === 'all' ? 'active' : ''}" data-teacher="all">Все учителя</button>` +
|
||||
teacherList.map(t =>
|
||||
`<button class="pill ${window._dvoikiTeacher === t.name ? 'active' : ''}" data-teacher="${t.name}">
|
||||
${t.name} ${t.open > 0 ? '<span class="pill-badge">' + t.open + '</span>' : ''}
|
||||
</button>`
|
||||
).join('') +
|
||||
'</div>';
|
||||
|
||||
document.getElementById('dvoikiPills').innerHTML = html;
|
||||
|
||||
document.querySelectorAll('#dvoikiPills .pill').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
if (btn.dataset.filter) {
|
||||
window._dvoikiFilter = btn.dataset.filter;
|
||||
}
|
||||
if (btn.dataset.teacher) {
|
||||
window._dvoikiTeacher = btn.dataset.teacher;
|
||||
}
|
||||
loadControl();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderDvoikiCards(rows) {
|
||||
const container = document.getElementById('dvoikiCards');
|
||||
|
||||
if (!rows.length) {
|
||||
container.innerHTML = '<div class="empty-state">Нет цепочек</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const gradeClass = (g) => {
|
||||
if (g === '2') return 's-2';
|
||||
if (g === '3') return 's-3';
|
||||
if (g === '4' || g === '5') return 's-45';
|
||||
if (g === 'X') return 's-stop';
|
||||
if (g === '—') return 's-empty';
|
||||
return 's-sym';
|
||||
};
|
||||
|
||||
container.innerHTML = rows.map(r => {
|
||||
const cardCls = r.resolved ? 'd-card resolved' : 'd-card unresolved';
|
||||
const statusIcon = r.resolved ? '✅' : '⚠️';
|
||||
const statusText = r.resolved ? `Закрыт (${r.reason})` : `Не закрыт — ${r.reason}`;
|
||||
|
||||
const stepsHtml = r.chain.map(c =>
|
||||
`<div class="step">
|
||||
<div class="step-date">${c.date}</div>
|
||||
<div class="step-grade ${gradeClass(c.grade)}">${c.grade}</div>
|
||||
</div>`
|
||||
).join('');
|
||||
|
||||
return `<div class="${cardCls}">
|
||||
<div class="d-card-top">
|
||||
<span class="d-card-status">${statusIcon} ${statusText}</span>
|
||||
<span class="d-card-steps">${r.chain.length} шаг${r.chain.length > 1 ? (r.chain.length < 5 ? 'а' : 'ов') : ''}</span>
|
||||
</div>
|
||||
<div class="d-card-student">${r.student}</div>
|
||||
<div class="d-card-subject">${r.subject} · ${r.teacher}</div>
|
||||
<div class="step-grid">${stepsHtml}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
loadFilters();
|
||||
loadAll();
|
||||
|
||||
@@ -16,13 +16,17 @@
|
||||
<section class="upload-section">
|
||||
<form id="uploadForm">
|
||||
<label class="drop-zone" id="dropZone">
|
||||
<input type="file" id="fileInput" accept=".pdf" hidden>
|
||||
<span class="drop-text">Перетащите PDF сюда или кликните для выбора</span>
|
||||
<input type="file" id="fileInput" accept=".pdf,.zip" hidden>
|
||||
<span class="drop-text">Перетащите PDF или ZIP сюда или кликните для выбора</span>
|
||||
<span class="drop-hint">(журнал класса)</span>
|
||||
</label>
|
||||
<button type="submit" id="uploadBtn" disabled>Загрузить</button>
|
||||
<button type="button" id="clearBtn" class="btn-danger">Очистить данные</button>
|
||||
<div id="uploadStatus"></div>
|
||||
<div id="uploadProgress" class="upload-progress hidden">
|
||||
<div class="progress-bar"><div class="progress-fill"></div></div>
|
||||
<span class="progress-text"></span>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -30,10 +34,13 @@
|
||||
<input type="text" id="gfStudent" placeholder="Ученик..." list="dlStudents">
|
||||
<input type="text" id="gfSubject" placeholder="Предмет..." list="dlSubjects">
|
||||
<input type="text" id="gfTeacher" placeholder="Учитель..." list="dlTeachers">
|
||||
<input type="text" id="gfClass" placeholder="Класс..." list="dlClasses">
|
||||
<input type="text" id="gfParallel" placeholder="Параллель..." list="dlParallels">
|
||||
<datalist id="dlParallels"></datalist>
|
||||
<input type="date" id="gfDateFrom" title="Дата с">
|
||||
<input type="date" id="gfDateTo" title="Дата по">
|
||||
<button id="gfApply">Применить</button>
|
||||
<button id="gfReset" class="btn-reset">Сбросить</button>
|
||||
<button id="gfReset" class="btn-danger">Сбросить</button>
|
||||
<datalist id="dlStudents"></datalist>
|
||||
<datalist id="dlSubjects"></datalist>
|
||||
<datalist id="dlTeachers"></datalist>
|
||||
@@ -46,6 +53,7 @@
|
||||
<button class="tab" data-tab="classes">Классы</button>
|
||||
<button class="tab" data-tab="daily">Подневно</button>
|
||||
<button class="tab" data-tab="pivot">Сводка</button>
|
||||
<button class="tab" data-tab="control">Контроль</button>
|
||||
</section>
|
||||
|
||||
<section id="dashboard" class="tab-content active">
|
||||
@@ -109,6 +117,32 @@
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="control" class="tab-content">
|
||||
<h2>Контроль</h2>
|
||||
<div class="control-tabs">
|
||||
<button class="ctrl-tab active" data-ctrl="homework">📋 Домашние задания</button>
|
||||
<button class="ctrl-tab" data-ctrl="grades">📊 Контроль оценок</button>
|
||||
<button class="ctrl-tab" data-ctrl="dvoiki">🎯 Двойки</button>
|
||||
</div>
|
||||
<div class="control-filters" id="ctrlFiltersHW">
|
||||
<label><input type="checkbox" id="ctrlNoTopic"> Без темы</label>
|
||||
<label><input type="checkbox" id="ctrlNoHomework"> Без ДЗ</label>
|
||||
</div>
|
||||
<div class="control-filters" id="ctrlFiltersGrades" style="display:none">
|
||||
<label>Мин. числовых оценок: <input type="number" id="ctrlMinGrades" value="3" min="0" max="100" style="width:60px"></label>
|
||||
</div>
|
||||
<div class="control-filters" id="ctrlFiltersDvoiki" style="display:none">
|
||||
<div class="dvoiki-pills" id="dvoikiPills"></div>
|
||||
</div>
|
||||
<div class="table-wrapper" id="ctrlTableWrap">
|
||||
<table id="controlTable">
|
||||
<thead><tr><th>Дата</th><th>Предмет</th><th>Учитель</th><th>Тема</th><th>ДЗ</th><th>Всего</th><th>Числ.</th><th>Симв.</th><th>Оценки</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="dvoiki-cards" id="dvoikiCards" style="display:none"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="modal-overlay" id="clearModal">
|
||||
|
||||
275
public/style.css
275
public/style.css
@@ -204,6 +204,56 @@ h2 { font-size: 18px; margin-bottom: 16px; color: #1a1a2e; }
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.upload-progress {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.upload-progress.hidden { display: none; }
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: #e5e5e5;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: #4f46e5;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.progress-fill:not(.determinate) {
|
||||
animation: progress-indeterminate 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes progress-indeterminate {
|
||||
0% { width: 0%; margin-left: 0%; }
|
||||
50% { width: 60%; margin-left: 20%; }
|
||||
100% { width: 0%; margin-left: 100%; }
|
||||
}
|
||||
.progress-text {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #d0d0d0;
|
||||
border-top-color: #4f46e5;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
vertical-align: middle;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.btn-danger {
|
||||
background: #dc2626;
|
||||
}
|
||||
@@ -393,6 +443,231 @@ h2 { font-size: 18px; margin-bottom: 16px; color: #1a1a2e; }
|
||||
color: #333;
|
||||
}
|
||||
.pivot-hidden { display: none; }
|
||||
|
||||
.control-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.ctrl-tab {
|
||||
padding: 7px 18px;
|
||||
border: 1px solid #d0d0d0;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
color: #555;
|
||||
}
|
||||
.ctrl-tab:first-child { border-radius: 6px 0 0 6px; }
|
||||
.ctrl-tab:last-child { border-radius: 0 6px 6px 0; }
|
||||
.ctrl-tab.active {
|
||||
background: #4f46e5;
|
||||
color: #fff;
|
||||
border-color: #4f46e5;
|
||||
}
|
||||
.control-filters {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.control-filters label {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.control-filters input[type="checkbox"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
.control-filters input[type="number"] {
|
||||
padding: 4px 6px;
|
||||
border: 1px solid #d0d0d0;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
#controlTable td.missing {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
font-style: italic;
|
||||
}
|
||||
#controlTable tr.red-row td {
|
||||
background: #fee2e2;
|
||||
}
|
||||
#controlTable td.grades-cell {
|
||||
max-width: 300px;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
font-size: 12px;
|
||||
}
|
||||
#controlTable tr.red-row td {
|
||||
background: #fee2e2;
|
||||
}
|
||||
#controlTable tr.green-row td {
|
||||
background: #dcfce7;
|
||||
}
|
||||
.chain-mini {
|
||||
border-collapse: collapse;
|
||||
display: inline-table;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.chain-mini td {
|
||||
padding: 2px 5px;
|
||||
border: 1px solid #e5e5e5;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
background: #fff;
|
||||
}
|
||||
.cm-date {
|
||||
color: #888;
|
||||
font-size: 11px !important;
|
||||
}
|
||||
.cm-grade {
|
||||
font-weight: 600;
|
||||
min-width: 22px;
|
||||
}
|
||||
.cm-bad {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.control-filters select {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #d0d0d0;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dvoiki-pills {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.pill {
|
||||
padding: 7px 16px;
|
||||
border: 1px solid #d0d0d0;
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
color: #555;
|
||||
}
|
||||
.pill:hover {
|
||||
border-color: #4f46e5;
|
||||
color: #4f46e5;
|
||||
}
|
||||
.pill.active {
|
||||
background: #4f46e5;
|
||||
color: #fff;
|
||||
border-color: #4f46e5;
|
||||
}
|
||||
.pill-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
background: #dc2626;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 0 5px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.dvoiki-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
|
||||
gap: 14px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.d-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid #e5e5e5;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
|
||||
}
|
||||
.d-card.unresolved {
|
||||
border-left: 4px solid #dc2626;
|
||||
}
|
||||
.d-card.resolved {
|
||||
border-left: 4px solid #16a34a;
|
||||
}
|
||||
|
||||
.d-card-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.d-card-status {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
.d-card-steps {
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
}
|
||||
.d-card-student {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.d-card-subject {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.step-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 42px;
|
||||
}
|
||||
.step-date {
|
||||
font-size: 10px;
|
||||
color: #999;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.step-grade {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
.s-2 { background: #dc2626; }
|
||||
.s-3 { background: #f59e0b; }
|
||||
.s-45 { background: #16a34a; }
|
||||
.s-sym { background: #e5e5e5; color: #888; }
|
||||
.s-stop {
|
||||
background: #fff;
|
||||
color: #dc2626;
|
||||
border: 2px solid #dc2626;
|
||||
font-weight: 900;
|
||||
}
|
||||
.s-empty {
|
||||
background: #fff;
|
||||
color: #999;
|
||||
border: 1px dashed #ccc;
|
||||
font-weight: 400;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
393
server.js
393
server.js
@@ -4,37 +4,57 @@ const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const { parsePdfBuffer } = require('./src/parser');
|
||||
const { upsertClass, upsertStudent, upsertSubject, upsertTeacher, upsertGradeValue, insertDailyGradeValue, db } = require('./src/db');
|
||||
const { upsertClass, upsertStudent, upsertSubject, upsertTeacher, upsertGradeValue, insertDailyGradeValue, insertLessonPlanValue, db } = require('./src/db');
|
||||
const { getFullStats, getSubjectDetails, getTeacherDetails, getClassDetails } = require('./src/stats');
|
||||
const AdmZip = require('adm-zip');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
function buildClassWhere(query) {
|
||||
const parts = [];
|
||||
const params = [];
|
||||
if (query.class) {
|
||||
parts.push('c.name LIKE ?');
|
||||
params.push('%' + query.class + '%');
|
||||
}
|
||||
if (query.parallel) {
|
||||
parts.push('c.name LIKE ?');
|
||||
params.push(query.parallel + '%');
|
||||
}
|
||||
return { sql: parts.length ? 'AND ' + parts.join(' AND ') : '', params };
|
||||
}
|
||||
|
||||
function CYRILLIC_MIN(text, min) {
|
||||
if (!text) return false;
|
||||
const m = text.match(/[а-яё]/gi);
|
||||
return (m && m.length >= min);
|
||||
}
|
||||
|
||||
const UPLOADS_DIR = path.join(__dirname, 'uploads');
|
||||
if (!fs.existsSync(UPLOADS_DIR)) fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
||||
|
||||
const upload = multer({
|
||||
dest: UPLOADS_DIR,
|
||||
limits: { fileSize: 50 * 1024 * 1024 },
|
||||
limits: { fileSize: 500 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype === 'application/pdf' || file.originalname.toLowerCase().endsWith('.pdf')) {
|
||||
const ext = file.originalname.toLowerCase();
|
||||
if (file.mimetype === 'application/pdf' || ext.endsWith('.pdf') ||
|
||||
file.mimetype === 'application/zip' || ext.endsWith('.zip') ||
|
||||
file.mimetype === 'application/x-zip-compressed') {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Только PDF файлы'));
|
||||
cb(new Error('Только PDF или ZIP файлы'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const tasks = new Map();
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
app.post('/api/upload', upload.single('pdf'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ error: 'Файл не загружен' });
|
||||
|
||||
const buffer = fs.readFileSync(req.file.path);
|
||||
const parsed = await parsePdfBuffer(buffer, req.file.originalname);
|
||||
|
||||
function processParsedPdf(parsed) {
|
||||
if (!parsed.className) throw new Error('Не удалось определить класс');
|
||||
if (!parsed.subjects || parsed.subjects.length === 0) throw new Error('Не найдены предметы');
|
||||
if (!parsed.students || parsed.students.length === 0) throw new Error('Не найдены ученики');
|
||||
@@ -42,7 +62,9 @@ app.post('/api/upload', upload.single('pdf'), async (req, res) => {
|
||||
const classId = upsertClass(parsed.className);
|
||||
if (!classId) throw new Error('Ошибка сохранения класса');
|
||||
|
||||
db.prepare(`DELETE FROM lesson_plans WHERE subject_id IN (SELECT DISTINCT d.subject_id FROM daily_grades d JOIN students s ON d.student_id = s.id WHERE s.class_id = ?)`).run(classId);
|
||||
db.prepare(`DELETE FROM daily_grades WHERE student_id IN (SELECT id FROM students WHERE class_id = ?)`).run(classId);
|
||||
db.prepare(`DELETE FROM lesson_plans WHERE subject_id NOT IN (SELECT DISTINCT subject_id FROM daily_grades)`).run();
|
||||
db.prepare(`DELETE FROM grades WHERE student_id IN (SELECT id FROM students WHERE class_id = ?)`).run(classId);
|
||||
|
||||
const subjectIds = {};
|
||||
@@ -102,18 +124,116 @@ app.post('/api/upload', upload.single('pdf'), async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
fs.unlinkSync(req.file.path);
|
||||
let lessonCount = 0;
|
||||
for (const lp of parsed.lessonPlans) {
|
||||
const subjectId = subjectIds[lp.subject];
|
||||
if (!subjectId) continue;
|
||||
let tId = null;
|
||||
if (lp.teacher && teacherIds[lp.teacher]) {
|
||||
tId = teacherIds[lp.teacher];
|
||||
} else if (teacherBySubject[lp.subject]) {
|
||||
tId = teacherIds[teacherBySubject[lp.subject]] || null;
|
||||
}
|
||||
insertLessonPlanValue(subjectId, tId, lp.date, lp.topic, lp.homework);
|
||||
lessonCount++;
|
||||
}
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
return {
|
||||
className: parsed.className,
|
||||
students: parsed.students.length,
|
||||
subjects: parsed.subjects.length,
|
||||
teachers: parsed.teachers.length,
|
||||
quarterlyGrades: quarterlyCount,
|
||||
dailyGrades: dailyCount,
|
||||
message: `Загружен класс ${parsed.className}: ${parsed.students.length} учеников, ${parsed.subjects.length} предметов, ${quarterlyCount} четвертных, ${dailyCount} подневных оценок`
|
||||
lessons: lessonCount
|
||||
};
|
||||
}
|
||||
|
||||
app.post('/api/upload', upload.single('pdf'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ error: 'Файл не загружен' });
|
||||
|
||||
const ext = req.file.originalname.toLowerCase();
|
||||
|
||||
if (ext.endsWith('.zip')) {
|
||||
const taskId = 'task_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
|
||||
const filePath = req.file.path;
|
||||
|
||||
tasks.set(taskId, { status: 'processing', done: 0, total: 0, elapsed: 0, startTime: Date.now(), filePath });
|
||||
|
||||
setImmediate(async () => {
|
||||
const task = tasks.get(taskId);
|
||||
if (!task) return;
|
||||
try {
|
||||
const zip = new AdmZip(filePath);
|
||||
const entries = zip.getEntries();
|
||||
const pdfEntries = entries.filter(e => !e.isDirectory && e.entryName.toLowerCase().endsWith('.pdf'));
|
||||
task.total = pdfEntries.length;
|
||||
const results = [];
|
||||
const errors = [];
|
||||
|
||||
for (let i = 0; i < pdfEntries.length; i++) {
|
||||
const entry = pdfEntries[i];
|
||||
try {
|
||||
const buf = entry.getData();
|
||||
const name = entry.entryName.replace(/^.*[\\/]/, '');
|
||||
const parsed = await parsePdfBuffer(buf, name);
|
||||
const r = processParsedPdf(parsed);
|
||||
results.push(r);
|
||||
task.lastFile = name;
|
||||
task.lastResult = r.className + ': ' + r.students + ' уч.';
|
||||
console.log('[OK] ' + name + ' → ' + r.className + ': ' + r.students + ' уч.');
|
||||
await new Promise(r => setImmediate(r));
|
||||
} catch (e) {
|
||||
errors.push(entry.entryName + ': ' + e.message);
|
||||
task.lastFile = entry.entryName;
|
||||
task.lastResult = 'ОШИБКА: ' + e.message;
|
||||
console.log('[ERR] ' + entry.entryName + ': ' + e.message);
|
||||
}
|
||||
task.done = i + 1;
|
||||
task.elapsed = Math.floor((Date.now() - task.startTime) / 1000);
|
||||
}
|
||||
|
||||
const clsSet = new Set(results.map(r => r.className));
|
||||
console.log('=== Готово: ' + results.length + '/' + task.total + ' файлов, ' + clsSet.size + ' классов, ' + results.reduce((s,r)=>s+r.students,0) + ' уч., ' + Math.floor((Date.now() - task.startTime)/1000) + 'с, ошибок: ' + errors.length + ' ===');
|
||||
if (errors.length) errors.forEach(e => console.log(' • ' + e));
|
||||
task.status = errors.length > 0 ? 'partial' : 'done';
|
||||
task.elapsed = Math.floor((Date.now() - task.startTime) / 1000);
|
||||
task.result = {
|
||||
classes: [...clsSet],
|
||||
students: results.reduce((s, r) => s + r.students, 0),
|
||||
files: results.length,
|
||||
totalFiles: pdfEntries.length,
|
||||
errors: errors.length,
|
||||
errorList: errors.slice(0, 10),
|
||||
elapsed: task.elapsed,
|
||||
message: `Загружено ${results.length} из ${pdfEntries.length} файлов, ${clsSet.size} классов, ${results.reduce((s, r) => s + r.students, 0)} учеников за ${task.elapsed}с` +
|
||||
(errors.length ? '\nОшибки:\n' + errors.map(e => ' • ' + e).join('\n') : '')
|
||||
};
|
||||
} catch (e) {
|
||||
task.status = 'error';
|
||||
task.error = e.message;
|
||||
}
|
||||
if (fs.existsSync(filePath)) try { fs.unlinkSync(filePath); } catch (_) {}
|
||||
});
|
||||
|
||||
res.json({ taskId, status: 'processing' });
|
||||
} else {
|
||||
const buffer = fs.readFileSync(req.file.path);
|
||||
const parsed = await parsePdfBuffer(buffer, req.file.originalname);
|
||||
const r = processParsedPdf(parsed);
|
||||
fs.unlinkSync(req.file.path);
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
classes: [r.className],
|
||||
students: r.students,
|
||||
dailyGrades: r.dailyGrades,
|
||||
quarterlyGrades: r.quarterlyGrades,
|
||||
lessons: r.lessons,
|
||||
message: `Загружен класс ${r.className}: ${r.students} учеников`
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (req.file && fs.existsSync(req.file.path)) {
|
||||
try { fs.unlinkSync(req.file.path); } catch (_) {}
|
||||
@@ -123,6 +243,30 @@ app.post('/api/upload', upload.single('pdf'), async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/task/:taskId', (req, res) => {
|
||||
const task = tasks.get(req.params.taskId);
|
||||
if (!task) return res.status(404).json({ error: 'Задача не найдена' });
|
||||
|
||||
const resp = {
|
||||
taskId: req.params.taskId,
|
||||
status: task.status,
|
||||
done: task.done,
|
||||
total: task.total,
|
||||
elapsed: Math.floor((Date.now() - task.startTime) / 1000)
|
||||
};
|
||||
if (task.status === 'done' || task.status === 'partial') {
|
||||
resp.result = task.result;
|
||||
}
|
||||
if (task.status === 'error') {
|
||||
resp.error = task.error;
|
||||
}
|
||||
res.json(resp);
|
||||
|
||||
if (task.status === 'done' || task.status === 'error') {
|
||||
setTimeout(() => tasks.delete(req.params.taskId), 60000);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/stats', (req, res) => {
|
||||
try {
|
||||
res.json(getFullStats(req.query));
|
||||
@@ -160,7 +304,9 @@ app.get('/api/filters', (req, res) => {
|
||||
const students = db.prepare(`SELECT DISTINCT s.full_name FROM daily_grades d JOIN students s ON d.student_id = s.id ORDER BY s.full_name`).all().map(r => r.full_name);
|
||||
const subjects = db.prepare(`SELECT DISTINCT sj.name FROM daily_grades d JOIN subjects sj ON d.subject_id = sj.id ORDER BY sj.name`).all().map(r => r.name);
|
||||
const teachers = db.prepare(`SELECT DISTINCT t.name FROM daily_grades d JOIN teachers t ON d.teacher_id = t.id WHERE t.name IS NOT NULL ORDER BY t.name`).all().map(r => r.name);
|
||||
res.json({ students, subjects, teachers });
|
||||
const classes = db.prepare(`SELECT DISTINCT c.name FROM daily_grades d JOIN students s ON d.student_id = s.id JOIN classes c ON s.class_id = c.id ORDER BY c.name`).all().map(r => r.name);
|
||||
const parallels = db.prepare(`SELECT DISTINCT substr(c.name, 1, length(c.name) - 1) as p FROM classes c WHERE c.name GLOB '[0-9]*' ORDER BY CAST(p AS INTEGER)`).all().map(r => r.p);
|
||||
res.json({ students, subjects, teachers, classes, parallels });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
@@ -169,6 +315,7 @@ app.get('/api/filters', (req, res) => {
|
||||
app.get('/api/daily', (req, res) => {
|
||||
try {
|
||||
const { student, subject, teacher, date_from, date_to, limit, offset } = req.query;
|
||||
const cls = buildClassWhere(req.query);
|
||||
let sql = `
|
||||
SELECT d.id, s.full_name as student, c.name as class, sj.name as subject,
|
||||
COALESCE(t.name, '—') as teacher,
|
||||
@@ -179,9 +326,9 @@ app.get('/api/daily', (req, res) => {
|
||||
JOIN classes c ON s.class_id = c.id
|
||||
JOIN subjects sj ON d.subject_id = sj.id
|
||||
LEFT JOIN teachers t ON d.teacher_id = t.id
|
||||
WHERE 1=1
|
||||
WHERE 1=1 ${cls.sql}
|
||||
`;
|
||||
const params = [];
|
||||
const params = [...cls.params];
|
||||
|
||||
if (student) { sql += ' AND s.full_name LIKE ?'; params.push('%' + student + '%'); }
|
||||
if (subject) { sql += ' AND sj.name LIKE ?'; params.push('%' + subject + '%'); }
|
||||
@@ -211,11 +358,12 @@ app.get('/api/daily', (req, res) => {
|
||||
let countSql = `
|
||||
SELECT COUNT(*) as c FROM daily_grades d
|
||||
JOIN students s ON d.student_id = s.id
|
||||
JOIN classes c ON s.class_id = c.id
|
||||
JOIN subjects sj ON d.subject_id = sj.id
|
||||
LEFT JOIN teachers t ON d.teacher_id = t.id
|
||||
WHERE 1=1
|
||||
WHERE 1=1 ${cls.sql}
|
||||
`;
|
||||
let countParams = [];
|
||||
let countParams = [...cls.params];
|
||||
if (student) { countSql += ' AND s.full_name LIKE ?'; countParams.push('%' + student + '%'); }
|
||||
if (subject) { countSql += ' AND sj.name LIKE ?'; countParams.push('%' + subject + '%'); }
|
||||
if (teacher) { countSql += ' AND t.name LIKE ?'; countParams.push('%' + teacher + '%'); }
|
||||
@@ -246,6 +394,7 @@ app.get('/api/daily', (req, res) => {
|
||||
app.get('/api/pivot', (req, res) => {
|
||||
try {
|
||||
const { period, student, subject, teacher, date_from, date_to } = req.query;
|
||||
const cls = buildClassWhere(req.query);
|
||||
const mode = period === 'week' ? 'week' : period === 'quarter' ? 'quarter' : 'month';
|
||||
|
||||
let sql = `
|
||||
@@ -258,10 +407,11 @@ app.get('/api/pivot', (req, res) => {
|
||||
FROM daily_grades d
|
||||
JOIN subjects sj ON d.subject_id = sj.id
|
||||
JOIN students s ON d.student_id = s.id
|
||||
JOIN classes c ON s.class_id = c.id
|
||||
LEFT JOIN teachers t ON d.teacher_id = t.id
|
||||
WHERE 1=1
|
||||
WHERE 1=1 ${cls.sql}
|
||||
`;
|
||||
const params = [];
|
||||
const params = [...cls.params];
|
||||
|
||||
if (student) { sql += ' AND s.full_name LIKE ?'; params.push('%' + student + '%'); }
|
||||
if (subject) { sql += ' AND sj.name LIKE ?'; params.push('%' + subject + '%'); }
|
||||
@@ -398,11 +548,201 @@ app.get('/api/pivot', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/lessons', (req, res) => {
|
||||
try {
|
||||
const { subject, teacher, date_from, date_to, no_topic, no_homework, low_grades, min_grades } = req.query;
|
||||
const cls = buildClassWhere(req.query);
|
||||
const minNumeric = parseInt(min_grades) || 3;
|
||||
|
||||
let sql = `
|
||||
SELECT lp.lesson_date,
|
||||
sj.name as subject,
|
||||
COALESCE(t.name, '—') as teacher,
|
||||
lp.topic,
|
||||
lp.homework,
|
||||
COUNT(d.id) as grades_total,
|
||||
SUM(CASE WHEN d.grade IS NOT NULL THEN 1 ELSE 0 END) as grades_numeric,
|
||||
SUM(CASE WHEN d.symbol IS NOT NULL THEN 1 ELSE 0 END) as grades_symbol,
|
||||
GROUP_CONCAT(COALESCE(CAST(d.grade AS TEXT), d.symbol), ',') as grades_list
|
||||
FROM lesson_plans lp
|
||||
JOIN subjects sj ON lp.subject_id = sj.id
|
||||
LEFT JOIN teachers t ON lp.teacher_id = t.id
|
||||
LEFT JOIN daily_grades d ON d.subject_id = lp.subject_id AND d.grade_date = lp.lesson_date
|
||||
WHERE 1=1
|
||||
`;
|
||||
const params = [];
|
||||
|
||||
if (cls.params.length > 0) {
|
||||
sql += ` AND lp.id IN (
|
||||
SELECT DISTINCT lp2.id FROM lesson_plans lp2
|
||||
JOIN daily_grades d2 ON d2.subject_id = lp2.subject_id AND d2.grade_date = lp2.lesson_date
|
||||
JOIN students s2 ON d2.student_id = s2.id
|
||||
JOIN classes c ON s2.class_id = c.id
|
||||
WHERE 1=1 ${cls.sql}
|
||||
)`;
|
||||
params.push(...cls.params);
|
||||
}
|
||||
|
||||
if (subject) { sql += ' AND sj.name LIKE ?'; params.push('%' + subject + '%'); }
|
||||
if (teacher) { sql += ' AND t.name LIKE ?'; params.push('%' + teacher + '%'); }
|
||||
if (date_from && date_to) {
|
||||
if (date_from <= date_to) {
|
||||
sql += ' AND lp.lesson_date >= ? AND lp.lesson_date <= ?';
|
||||
params.push(date_from, date_to);
|
||||
} else {
|
||||
sql += ' AND (lp.lesson_date >= ? OR lp.lesson_date <= ?)';
|
||||
params.push(date_from, date_to);
|
||||
}
|
||||
} else if (date_from) {
|
||||
sql += ' AND lp.lesson_date >= ?';
|
||||
params.push(date_from);
|
||||
} else if (date_to) {
|
||||
sql += ' AND lp.lesson_date <= ?';
|
||||
params.push(date_to);
|
||||
}
|
||||
|
||||
if (no_topic === 'true') sql += ' AND lp.topic IS NULL';
|
||||
if (no_homework === 'true') sql += ' AND lp.homework IS NULL';
|
||||
|
||||
sql += ' GROUP BY lp.lesson_date, lp.subject_id';
|
||||
|
||||
if (low_grades === 'true') {
|
||||
sql += ' HAVING SUM(CASE WHEN d.grade IS NOT NULL THEN 1 ELSE 0 END) < ?';
|
||||
params.push(minNumeric);
|
||||
}
|
||||
|
||||
sql += ' ORDER BY lp.lesson_date, sj.name';
|
||||
|
||||
const rows = db.prepare(sql).all(...params);
|
||||
|
||||
for (const r of rows) {
|
||||
r.lesson_date = r.lesson_date.substring(3, 5) + '.' + r.lesson_date.substring(0, 2);
|
||||
r.grades_numeric = r.grades_numeric || 0;
|
||||
r.grades_symbol = r.grades_symbol || 0;
|
||||
r.grades_total = r.grades_total || 0;
|
||||
|
||||
r.topic_invalid = !!(r.topic && !CYRILLIC_MIN(r.topic, 3));
|
||||
r.homework_invalid = !!(r.homework && !CYRILLIC_MIN(r.homework, 3));
|
||||
|
||||
if (r.topic_invalid) r.topic = null;
|
||||
if (r.homework_invalid) r.homework = null;
|
||||
}
|
||||
|
||||
res.json({ rows, min_grades: minNumeric });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/dvoiki', (req, res) => {
|
||||
try {
|
||||
const { student, subject, teacher, date_from, date_to } = req.query;
|
||||
const cls = buildClassWhere(req.query);
|
||||
|
||||
let sql = `
|
||||
SELECT d.grade_date, d.grade, d.symbol,
|
||||
sj.name as subject,
|
||||
COALESCE(t.name, '—') as teacher,
|
||||
s.full_name as student,
|
||||
c.name as class
|
||||
FROM daily_grades d
|
||||
JOIN students s ON d.student_id = s.id
|
||||
JOIN classes c ON s.class_id = c.id
|
||||
JOIN subjects sj ON d.subject_id = sj.id
|
||||
LEFT JOIN teachers t ON d.teacher_id = t.id
|
||||
WHERE 1=1 ${cls.sql}
|
||||
`;
|
||||
const params = [...cls.params];
|
||||
|
||||
if (student) { sql += ' AND s.full_name LIKE ?'; params.push('%' + student + '%'); }
|
||||
if (subject) { sql += ' AND sj.name LIKE ?'; params.push('%' + subject + '%'); }
|
||||
if (teacher) { sql += ' AND t.name LIKE ?'; params.push('%' + teacher + '%'); }
|
||||
if (date_from && date_to) {
|
||||
if (date_from <= date_to) {
|
||||
sql += ' AND d.grade_date >= ? AND d.grade_date <= ?';
|
||||
params.push(date_from, date_to);
|
||||
} else {
|
||||
sql += ' AND (d.grade_date >= ? OR d.grade_date <= ?)';
|
||||
params.push(date_from, date_to);
|
||||
}
|
||||
} else if (date_from) {
|
||||
sql += ' AND d.grade_date >= ?';
|
||||
params.push(date_from);
|
||||
} else if (date_to) {
|
||||
sql += ' AND d.grade_date <= ?';
|
||||
params.push(date_to);
|
||||
}
|
||||
|
||||
sql += ' ORDER BY s.full_name, sj.name, d.grade_date';
|
||||
|
||||
const rows = db.prepare(sql).all(...params);
|
||||
|
||||
const chains = [];
|
||||
const byPair = {};
|
||||
for (const r of rows) {
|
||||
const key = r.student + '|' + r.subject;
|
||||
if (!byPair[key]) byPair[key] = [];
|
||||
byPair[key].push(r);
|
||||
}
|
||||
|
||||
for (const [key, grades] of Object.entries(byPair)) {
|
||||
let chain = null;
|
||||
|
||||
for (const g of grades) {
|
||||
if (g.grade === 2) {
|
||||
if (!chain) {
|
||||
chain = { student: g.student, class: g.class, subject: g.subject, teacher: g.teacher, started: g.grade_date, chain: [], resolved: false, reason: '' };
|
||||
}
|
||||
chain.chain.push({ date: g.grade_date, grade: String(g.grade) });
|
||||
} else if (chain && g.grade != null && g.grade >= 3) {
|
||||
chain.chain.push({ date: g.grade_date, grade: String(g.grade) });
|
||||
chain.resolved = true;
|
||||
chain.reason = 'оценка ' + g.grade;
|
||||
chains.push(chain);
|
||||
chain = null;
|
||||
} else if (chain && g.grade === null && g.symbol === null) {
|
||||
chain.chain.push({ date: g.grade_date, grade: 'X' });
|
||||
chain.reason = 'пустая клетка';
|
||||
chains.push(chain);
|
||||
chain = null;
|
||||
} else if (chain) {
|
||||
chain.chain.push({ date: g.grade_date, grade: g.grade !== null ? String(g.grade) : (g.symbol || 'X') });
|
||||
if (g.grade === null && g.symbol === null) {
|
||||
chain.reason = 'пустая клетка';
|
||||
chains.push(chain);
|
||||
chain = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (chain && !chain.resolved) {
|
||||
chain.reason = chain.reason || 'нет уроков после';
|
||||
chain.objective = (chain.reason === 'нет уроков после' && chain.chain.length === 1);
|
||||
chains.push(chain);
|
||||
}
|
||||
}
|
||||
|
||||
chains.sort((a, b) => b.started.localeCompare(a.started) || a.student.localeCompare(b.student));
|
||||
|
||||
for (const c of chains) {
|
||||
c.started = c.started.substring(3, 5) + '.' + c.started.substring(0, 2);
|
||||
for (const g of c.chain) {
|
||||
g.date = g.date.substring(3, 5) + '.' + g.date.substring(0, 2);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ rows: chains });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/classes/:name', (req, res) => {
|
||||
try {
|
||||
const cls = db.prepare(`SELECT id FROM classes WHERE name = ?`).get(req.params.name);
|
||||
if (!cls) return res.status(404).json({ error: 'Класс не найден' });
|
||||
|
||||
db.prepare(`DELETE FROM lesson_plans WHERE subject_id IN (SELECT DISTINCT d.subject_id FROM daily_grades d JOIN students s ON d.student_id = s.id WHERE s.class_id = ?)`).run(cls.id);
|
||||
db.prepare(`DELETE FROM daily_grades WHERE student_id IN (SELECT id FROM students WHERE class_id = ?)`).run(cls.id);
|
||||
db.prepare(`DELETE FROM grades WHERE student_id IN (SELECT id FROM students WHERE class_id = ?)`).run(cls.id);
|
||||
db.prepare(`DELETE FROM students WHERE class_id = ?`).run(cls.id);
|
||||
@@ -420,11 +760,13 @@ app.delete('/api/parallel/:grade', (req, res) => {
|
||||
if (!classes.length) return res.status(404).json({ error: 'Классы не найдены' });
|
||||
|
||||
for (const cls of classes) {
|
||||
db.prepare(`DELETE FROM lesson_plans WHERE subject_id IN (SELECT DISTINCT d.subject_id FROM daily_grades d JOIN students s ON d.student_id = s.id WHERE s.class_id = ?)`).run(cls.id);
|
||||
db.prepare(`DELETE FROM daily_grades WHERE student_id IN (SELECT id FROM students WHERE class_id = ?)`).run(cls.id);
|
||||
db.prepare(`DELETE FROM grades WHERE student_id IN (SELECT id FROM students WHERE class_id = ?)`).run(cls.id);
|
||||
db.prepare(`DELETE FROM students WHERE class_id = ?`).run(cls.id);
|
||||
db.prepare(`DELETE FROM classes WHERE id = ?`).run(cls.id);
|
||||
}
|
||||
db.prepare(`DELETE FROM lesson_plans WHERE subject_id NOT IN (SELECT DISTINCT subject_id FROM daily_grades)`).run();
|
||||
|
||||
res.json({ ok: true, message: `Параллель ${req.params.grade} удалена (${classes.length} классов)` });
|
||||
} catch (e) {
|
||||
@@ -434,7 +776,7 @@ app.delete('/api/parallel/:grade', (req, res) => {
|
||||
|
||||
app.delete('/api/data', (req, res) => {
|
||||
try {
|
||||
db.exec('DELETE FROM daily_grades; DELETE FROM grades; DELETE FROM teachers; DELETE FROM students; DELETE FROM subjects; DELETE FROM classes;');
|
||||
db.exec('DELETE FROM lesson_plans; DELETE FROM daily_grades; DELETE FROM grades; DELETE FROM teachers; DELETE FROM students; DELETE FROM subjects; DELETE FROM classes;');
|
||||
res.json({ ok: true, message: 'Все данные удалены' });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
@@ -442,7 +784,7 @@ app.delete('/api/data', (req, res) => {
|
||||
});
|
||||
|
||||
app.use((err, req, res, next) => {
|
||||
if (err.message === 'Только PDF файлы') {
|
||||
if (err.message === 'Только PDF или ZIP файлы') {
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
@@ -460,6 +802,9 @@ const server = app.listen(PORT, () => {
|
||||
console.log(`Сервер запущен: http://localhost:${PORT}`);
|
||||
console.log('Нажмите Ctrl+C для остановки');
|
||||
});
|
||||
server.timeout = 600000;
|
||||
server.keepAliveTimeout = 65000;
|
||||
server.headersTimeout = 66000;
|
||||
|
||||
server.on('error', (err) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
|
||||
28
src/db.js
28
src/db.js
@@ -67,6 +67,22 @@ db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_daily_subject ON daily_grades(subject_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_daily_teacher ON daily_grades(teacher_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_daily_date ON daily_grades(grade_date);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lesson_plans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
subject_id INTEGER NOT NULL,
|
||||
teacher_id INTEGER,
|
||||
lesson_date TEXT NOT NULL,
|
||||
topic TEXT,
|
||||
homework TEXT,
|
||||
UNIQUE(subject_id, lesson_date),
|
||||
FOREIGN KEY (subject_id) REFERENCES subjects(id),
|
||||
FOREIGN KEY (teacher_id) REFERENCES teachers(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lp_subject ON lesson_plans(subject_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_lp_teacher ON lesson_plans(teacher_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_lp_date ON lesson_plans(lesson_date);
|
||||
`);
|
||||
|
||||
const getClass = db.prepare(`SELECT id FROM classes WHERE name = ?`);
|
||||
@@ -126,8 +142,17 @@ function insertDailyGradeValue(studentId, subjectId, teacherId, gradeDate, grade
|
||||
insertDailyGrade.run(studentId, subjectId, teacherId || null, gradeDate, num, sym);
|
||||
}
|
||||
|
||||
const insertLessonPlan = db.prepare(`
|
||||
INSERT OR REPLACE INTO lesson_plans (subject_id, teacher_id, lesson_date, topic, homework)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
function insertLessonPlanValue(subjectId, teacherId, lessonDate, topic, homework) {
|
||||
insertLessonPlan.run(subjectId, teacherId || null, lessonDate, topic || null, homework || null);
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
db.exec('DELETE FROM daily_grades; DELETE FROM grades; DELETE FROM teachers; DELETE FROM students; DELETE FROM subjects; DELETE FROM classes;');
|
||||
db.exec('DELETE FROM lesson_plans; DELETE FROM daily_grades; DELETE FROM grades; DELETE FROM teachers; DELETE FROM students; DELETE FROM subjects; DELETE FROM classes;');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -138,6 +163,7 @@ module.exports = {
|
||||
upsertTeacher,
|
||||
upsertGradeValue,
|
||||
insertDailyGradeValue,
|
||||
insertLessonPlanValue,
|
||||
clearAll,
|
||||
DATA_DIR
|
||||
};
|
||||
|
||||
@@ -379,6 +379,75 @@ function nextSubjectIndex(text, from) {
|
||||
return m ? m.index : text.length;
|
||||
}
|
||||
|
||||
const HW_KEYWORDS = [
|
||||
'Повторить', 'Читать', 'Выполнить', 'Выучить', 'Подготовить',
|
||||
'Решить', 'Решаем', 'Сдать', 'Записать', 'Написать', 'Ответить',
|
||||
'Принести', 'Перейти', 'Письменно', 'Дз:', 'ДЗ:', 'дз:', 'Д.з.',
|
||||
'Стр.', 'с.', 'Выполняем', 'Готовимся', 'Работа', 'Повторяем',
|
||||
'Сделать', 'Нарисовать', 'Перерисовать', 'Посмотреть', 'Прочитать'
|
||||
];
|
||||
|
||||
function splitTopicHomework(text) {
|
||||
if (!text) return { topic: null, homework: null };
|
||||
|
||||
for (const kw of HW_KEYWORDS) {
|
||||
const idx = text.indexOf(kw);
|
||||
if (idx > 0) {
|
||||
const topic = text.substring(0, idx).trim().replace(/\s+$/, '');
|
||||
const homework = text.substring(idx).trim();
|
||||
return {
|
||||
topic: topic || null,
|
||||
homework: homework || null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { topic: text.trim() || null, homework: null };
|
||||
}
|
||||
|
||||
function parseLessonPlans(text) {
|
||||
const results = [];
|
||||
|
||||
const sections = splitSubjectSections(text);
|
||||
|
||||
for (const sec of sections) {
|
||||
const teacher = extractTeacherFromSection(sec.text);
|
||||
|
||||
const dateRe = /^(\d{2}\.\d{2})\s?(.*)/gm;
|
||||
let m;
|
||||
while ((m = dateRe.exec(sec.text)) !== null) {
|
||||
const dateParts = m[1].split('.');
|
||||
const sortableDate = dateParts.length === 2 ? dateParts[1] + '-' + dateParts[0] : m[1];
|
||||
const rawText = m[2].trim();
|
||||
|
||||
if (/^\d+$/.test(rawText)) continue;
|
||||
if (/^(?:За|Дано|Число|МЕСЯЦ|ЧИСЛО|Список|№)/i.test(rawText)) continue;
|
||||
if (rawText.length < 2) {
|
||||
results.push({
|
||||
subject: sec.name,
|
||||
teacher,
|
||||
date: sortableDate,
|
||||
topic: null,
|
||||
homework: null
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const { topic, homework } = splitTopicHomework(rawText);
|
||||
|
||||
results.push({
|
||||
subject: sec.name,
|
||||
teacher,
|
||||
date: sortableDate,
|
||||
topic,
|
||||
homework
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function parsePdfBuffer(buffer, filename) {
|
||||
const text = await extractText(buffer);
|
||||
if (!text || text.length < 100) throw new Error('Не удалось извлечь текст из PDF');
|
||||
@@ -403,6 +472,8 @@ async function parsePdfBuffer(buffer, filename) {
|
||||
|
||||
const dailyGrades = parseDailyGrades(text);
|
||||
|
||||
const lessonPlans = parseLessonPlans(text);
|
||||
|
||||
const finalSubjects = summarySubjects.length > 0 ? summarySubjects : subjects;
|
||||
|
||||
const allGrades = summaryGrades.length > 0 ? summaryGrades : [];
|
||||
@@ -414,6 +485,7 @@ async function parsePdfBuffer(buffer, filename) {
|
||||
teachers,
|
||||
allGrades,
|
||||
dailyGrades,
|
||||
lessonPlans,
|
||||
rawSummary: summaryGrades
|
||||
};
|
||||
}
|
||||
|
||||
11
src/stats.js
11
src/stats.js
@@ -15,6 +15,14 @@ function buildFilter(filters) {
|
||||
conditions.push('t.name LIKE ?');
|
||||
params.push('%' + filters.teacher + '%');
|
||||
}
|
||||
if (filters.class) {
|
||||
conditions.push('c.name LIKE ?');
|
||||
params.push('%' + filters.class + '%');
|
||||
}
|
||||
if (filters.parallel) {
|
||||
conditions.push('c.name LIKE ?');
|
||||
params.push(filters.parallel + '%');
|
||||
}
|
||||
if (filters.date_from && filters.date_to) {
|
||||
if (filters.date_from <= filters.date_to) {
|
||||
conditions.push('d.grade_date >= ? AND d.grade_date <= ?');
|
||||
@@ -64,6 +72,7 @@ function getFullStats(filters = {}) {
|
||||
FROM daily_grades d
|
||||
JOIN subjects sj ON d.subject_id = sj.id
|
||||
JOIN students s ON d.student_id = s.id
|
||||
JOIN classes c ON s.class_id = c.id
|
||||
LEFT JOIN teachers t ON d.teacher_id = t.id
|
||||
WHERE 1=1 ${f.where}
|
||||
GROUP BY sj.name ORDER BY student_count DESC, total DESC
|
||||
@@ -85,6 +94,7 @@ function getFullStats(filters = {}) {
|
||||
JOIN teachers t ON d.teacher_id = t.id
|
||||
JOIN subjects sj ON d.subject_id = sj.id
|
||||
JOIN students s ON d.student_id = s.id
|
||||
JOIN classes c ON s.class_id = c.id
|
||||
WHERE 1=1 ${f.where}
|
||||
GROUP BY t.id ORDER BY total ASC
|
||||
`).all(...f.params);
|
||||
@@ -135,6 +145,7 @@ function getFullStats(filters = {}) {
|
||||
const totalDaily = db.prepare(`
|
||||
SELECT COUNT(*) as c FROM daily_grades d
|
||||
JOIN students s ON d.student_id = s.id
|
||||
JOIN classes c ON s.class_id = c.id
|
||||
JOIN subjects sj ON d.subject_id = sj.id
|
||||
LEFT JOIN teachers t ON d.teacher_id = t.id
|
||||
WHERE 1=1 ${f.where}
|
||||
|
||||
Reference in New Issue
Block a user