This commit is contained in:
2026-07-01 13:16:07 +05:00
parent 50e8265b74
commit 96230759b7
7 changed files with 217 additions and 4 deletions

View File

@@ -75,6 +75,20 @@ async function initDb() {
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS exam_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
student_id INTEGER NOT NULL,
subject_code TEXT NOT NULL,
field_name TEXT NOT NULL,
old_value TEXT,
new_value TEXT,
changed_by TEXT,
changed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
source TEXT DEFAULT 'manual'
)
`);
db.run('INSERT OR IGNORE INTO subjects (code, name, is_mandatory) VALUES (?, ?, 1)', ['01', 'Русский язык']);
db.run('INSERT OR IGNORE INTO subjects (code, name, is_mandatory) VALUES (?, ?, 1)', ['02', 'Математика']);

View File

@@ -60,6 +60,17 @@ router.post('/import', upload.single('xlsfile'), (req, res) => {
if (!existing || existing.grade === null) code = '02';
}
const oldRow = dbGet("SELECT primary_score, grade FROM exam_results WHERE student_id = ? AND subject_code = ?", [sid, code]);
const changedBy = (req.session && req.session.user) ? req.session.user.login : 'import';
if (!oldRow) {
if (r.primary_score !== null) logHistory(sid, code, 'primary_score', null, String(r.primary_score), changedBy, 'import');
if (r.grade !== null) logHistory(sid, code, 'grade', null, String(r.grade), changedBy, 'import');
} else {
if (String(oldRow.primary_score) !== String(r.primary_score)) logHistory(sid, code, 'primary_score', oldRow.primary_score, r.primary_score, changedBy, 'import');
if (String(oldRow.grade) !== String(r.grade)) logHistory(sid, code, 'grade', oldRow.grade, r.grade, changedBy, 'import');
}
dbRun('INSERT OR REPLACE INTO exam_results (student_id, subject_code, primary_score, grade) VALUES (?, ?, ?, ?)', [sid, code, r.primary_score, r.grade]);
}
}
@@ -154,19 +165,46 @@ router.get('/students/:id', (req, res) => {
ORDER BY s.is_mandatory DESC, s.name
`, [req.params.id]);
res.json({ student, results });
const history = dbAll(`
SELECT h.*, s.name as subject_name
FROM exam_history h
LEFT JOIN subjects s ON s.code = h.subject_code
WHERE h.student_id = ?
ORDER BY h.changed_at DESC
LIMIT 50
`, [req.params.id]);
res.json({ student, results, history });
});
router.put('/students/:id/mandatory', (req, res) => {
const { id } = req.params;
const { rus_score, rus_grade, math_score, math_grade } = req.body;
const changedBy = (req.session && req.session.user) ? req.session.user.login : 'unknown';
dbBegin();
const updateSubject = (code, score, grade) => {
const oldRow = dbGet("SELECT primary_score, grade FROM exam_results WHERE student_id = ? AND subject_code = ?", [id, code]);
if (!oldRow) {
if (score !== undefined && score !== null && score !== '') logHistory(id, code, 'primary_score', null, String(score), changedBy, 'manual');
if (grade !== undefined && grade !== null && grade !== '') logHistory(id, code, 'grade', null, String(grade), changedBy, 'manual');
} else {
const newScore = (score !== undefined && score !== '' && score !== null) ? score : null;
const newGrade = (grade !== undefined && grade !== '' && grade !== null) ? grade : null;
if (String(oldRow.primary_score) !== String(newScore)) logHistory(id, code, 'primary_score', oldRow.primary_score, newScore, changedBy, 'manual');
if (String(oldRow.grade) !== String(newGrade)) logHistory(id, code, 'grade', oldRow.grade, newGrade, changedBy, 'manual');
}
dbRun('INSERT OR REPLACE INTO exam_results (student_id, subject_code, primary_score, grade) VALUES (?, ?, ?, ?)', [id, code, score || null, grade || null]);
};
if (rus_score !== undefined || rus_grade !== undefined) {
dbRun('INSERT OR REPLACE INTO exam_results (student_id, subject_code, primary_score, grade) VALUES (?, ?, ?, ?)', [id, '01', rus_score || null, rus_grade || null]);
updateSubject('01', rus_score, rus_grade);
}
if (math_score !== undefined || math_grade !== undefined) {
dbRun('INSERT OR REPLACE INTO exam_results (student_id, subject_code, primary_score, grade) VALUES (?, ?, ?, ?)', [id, '02', math_score || null, math_grade || null]);
updateSubject('02', math_score, math_grade);
}
dbCommit();
saveDb();
@@ -187,4 +225,12 @@ router.delete('/data', (req, res) => {
res.json({ ok: true });
});
function logHistory(studentId, subjectCode, fieldName, oldValue, newValue, changedBy, source) {
if (String(oldValue) === String(newValue)) return;
dbRun(
'INSERT INTO exam_history (student_id, subject_code, field_name, old_value, new_value, changed_by, source, changed_at) VALUES (?, ?, ?, ?, ?, ?, ?, datetime(\'now\'))',
[studentId, subjectCode, fieldName, oldValue !== null && oldValue !== undefined ? String(oldValue) : null, newValue !== null && newValue !== undefined ? String(newValue) : null, changedBy || 'unknown', source || 'manual']
);
}
module.exports = router;

View File

@@ -1,6 +1,5 @@
const express = require('express');
const { dbGet, dbAll } = require('../db');
const router = express.Router();
const requireAuth = (req, res, next) => {
@@ -34,11 +33,38 @@ router.get('/student/:id', requireAuth, (req, res) => {
const rusResult = mandatoryResults.find(r => r.subject_code === '01') || null;
const mathResult = mandatoryResults.find(r => r.subject_code === '02') || null;
const history = dbAll(`
SELECT h.*, s.name as subject_name
FROM exam_history h
LEFT JOIN subjects s ON s.code = h.subject_code
WHERE h.student_id = ?
ORDER BY h.changed_at DESC
LIMIT 50
`, [req.params.id]);
// Group history by timestamp+user+source
const groupedHistory = [];
let currentGroup = null;
for (const row of history) {
const key = `${row.changed_at}|${row.changed_by}|${row.source}`;
if (!currentGroup || currentGroup.key !== key) {
currentGroup = { key, changed_at: row.changed_at, changed_by: row.changed_by, source: row.source, changes: [] };
groupedHistory.push(currentGroup);
}
currentGroup.changes.push({
subject_name: row.subject_name || row.subject_code,
field_name: row.field_name === 'primary_score' ? 'первичный балл' : 'оценка',
old_value: row.old_value !== null ? row.old_value : '—',
new_value: row.new_value !== null ? row.new_value : '—'
});
}
res.render('student', {
student,
electiveResults,
rusResult,
mathResult,
groupedHistory,
user: req.session.user
});
});
@@ -232,4 +258,18 @@ router.get('/batch-print', requireAuth, (req, res) => {
res.render('batch-certificate', { students: studentData });
});
router.get('/history', requireAuth, (req, res) => {
const rows = dbAll(`
SELECT h.*, s.name as subject_name,
st.last_name, st.first_name, st.patronymic, st.class, st.id as student_id
FROM exam_history h
LEFT JOIN subjects s ON s.code = h.subject_code
LEFT JOIN students st ON st.id = h.student_id
ORDER BY h.changed_at DESC
LIMIT 200
`);
res.render('history', { rows, user: req.session.user });
});
module.exports = router;

76
views/history.ejs Normal file
View File

@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>История изменений — Справки</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="container">
<nav class="nav">
<a href="/" class="btn">&larr; К списку</a>
<span class="user-bar"><%= user ? user.name : '' %></span>
<button id="logout-btn" class="btn btn-logout">Выйти</button>
</nav>
<h1>История изменений</h1>
<table>
<thead>
<tr>
<th>Дата</th>
<th>Ученик</th>
<th>Класс</th>
<th>Предмет</th>
<th>Поле</th>
<th>Было</th>
<th>Стало</th>
<th>Пользователь</th>
<th>Источник</th>
</tr>
</thead>
<tbody>
<% rows.forEach(r => { %>
<tr>
<td style="font-size:12px;white-space:nowrap"><%= r.changed_at %></td>
<td><a href="/student/<%= r.student_id %>"><%= r.last_name %> <%= r.first_name %> <%= r.patronymic %></a></td>
<td><%= r.class %></td>
<td><%= r.subject_name || r.subject_code %></td>
<td><%= r.field_name === 'primary_score' ? 'балл' : 'оценка' %></td>
<td style="color:#999"><%= r.old_value !== null ? r.old_value : '—' %></td>
<td><strong><%= r.new_value !== null ? r.new_value : '—' %></strong></td>
<td style="font-size:12px"><%= r.changed_by || '—' %></td>
<td style="font-size:12px">
<% if (r.source === 'import') { %>импорт
<% } else if (r.source === 'manual') { %>вручную
<% } else { %><%= r.source %><% } %>
</td>
</tr>
<% }) %>
<% if (rows.length === 0) { %>
<tr><td colspan="9" style="text-align:center;color:#999;padding:20px">Нет записей</td></tr>
<% } %>
</tbody>
</table>
</div>
<script>
window.__CSRF_TOKEN = '<%= csrfToken %>';
const _origFetch = window.fetch.bind(window);
window.fetch = function(input, init = {}) {
init.credentials = 'include';
const method = (init.method || 'GET').toUpperCase();
if (['POST','PUT','PATCH','DELETE'].includes(method)) {
if (!init.headers) init.headers = {};
init.headers['X-CSRF-Token'] = window.__CSRF_TOKEN || '';
}
return _origFetch(input, init);
};
document.getElementById('logout-btn').addEventListener('click', async () => {
await fetch('/api/logout', { method: 'POST' });
window.location.href = '/login';
});
</script>
</body>
</html>

View File

@@ -10,6 +10,7 @@
<div class="container">
<nav class="nav">
<a href="/" class="btn">&larr; К списку</a>
<a href="/history" class="btn" style="background:#5a3e85">История</a>
<span class="user-bar"><%= user ? user.name : '' %></span>
<button id="logout-btn" class="btn btn-logout">Выйти</button>
</nav>

View File

@@ -13,6 +13,7 @@
<nav class="nav">
<a href="/" class="btn">Список учеников</a>
<a href="/import" class="btn btn-green">Импорт XLS</a>
<a href="/history" class="btn" style="background:#5a3e85">История</a>
<span class="user-bar"><%= user ? user.name : '' %></span>
<button id="logout-btn" class="btn btn-logout">Выйти</button>
</nav>

View File

@@ -10,6 +10,7 @@
<div class="container">
<nav class="nav">
<a href="/" class="btn">&larr; К списку</a>
<a href="/history" class="btn" style="background:#5a3e85">История</a>
<span class="user-bar"><%= user ? user.name : '' %></span>
<button id="logout-btn" class="btn btn-logout">Выйти</button>
</nav>
@@ -100,6 +101,40 @@
<button type="submit" class="btn btn-blue" style="margin-top:15px">Сформировать и распечатать</button>
</form>
</div>
<% if (groupedHistory && groupedHistory.length > 0) { %>
<div class="card">
<h2>История изменений</h2>
<table>
<thead>
<tr>
<th>Дата</th>
<th>Пользователь</th>
<th>Источник</th>
<th>Изменения</th>
</tr>
</thead>
<tbody>
<% groupedHistory.forEach(g => { %>
<tr>
<td style="white-space:nowrap;font-size:12px"><%= g.changed_at %></td>
<td style="font-size:12px"><%= g.changed_by %></td>
<td style="font-size:12px">
<% if (g.source === 'import') { %>XLS импорт
<% } else if (g.source === 'manual') { %>вручную
<% } else { %><%= g.source %><% } %>
</td>
<td style="font-size:12px">
<% g.changes.forEach((c, ci) => { %>
<div><%= c.subject_name %>: <%= c.field_name %> <span style="color:#999"><%= c.old_value %></span> → <strong><%= c.new_value %></strong></div>
<% }) %>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<% } %>
</div>
<script>