Склонение ФИО

This commit is contained in:
2026-07-02 15:34:46 +05:00
parent d8a19ab484
commit 8b5c03219f
6 changed files with 417 additions and 3 deletions

View File

@@ -132,3 +132,19 @@ ol li { margin: 4px 0; }
font-size: 13px;
color: #666;
}
.decl-input {
max-width: 180px;
font-size: 13px;
padding: 4px 6px;
}
.decl-input:disabled {
background: #f0f0f0;
color: #999;
cursor: not-allowed;
}
.decl-cb {
width: 18px;
height: 18px;
cursor: pointer;
}

View File

@@ -109,6 +109,9 @@ async function initDb() {
// Миграция: перенос Русского/Математики в обязательные слоты 01/02, если они пусты
migrateMandatorySubjects();
// Миграция: таблица склонений ФИО
migrateDeclensions();
console.log('БД инициализирована');
return db;
}
@@ -164,6 +167,27 @@ function migrateMandatorySubjects() {
}
}
function migrateDeclensions() {
if (!db) return;
db.run(`
CREATE TABLE IF NOT EXISTS name_declensions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
last_name TEXT NOT NULL,
first_name TEXT NOT NULL,
patronymic TEXT DEFAULT '',
last_name_declined TEXT DEFAULT '',
first_name_declined TEXT DEFAULT '',
patronymic_declined TEXT DEFAULT '',
no_decline_last INTEGER DEFAULT 0,
no_decline_first INTEGER DEFAULT 0,
no_decline_patr INTEGER DEFAULT 0,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(last_name, first_name, patronymic)
)
`);
saveDb();
}
function getDb() {
if (!db) throw new Error('БД не инициализирована.');
return db;

View File

@@ -246,6 +246,82 @@ router.get('/classes', (req, res) => {
res.json(rows.map(r => r.class));
});
router.get('/declensions', (req, res) => {
const { last_name, first_name, patronymic } = req.query;
const whereClauses = [];
const params = [];
if (last_name) {
whereClauses.push('s.last_name LIKE ?');
params.push(`%${last_name}%`);
}
if (first_name) {
whereClauses.push('s.first_name LIKE ?');
params.push(`%${first_name}%`);
}
if (patronymic) {
whereClauses.push('s.patronymic LIKE ?');
params.push(`%${patronymic}%`);
}
const wherePart = whereClauses.length ? 'WHERE ' + whereClauses.join(' AND ') : '';
const rows = dbAll(`
SELECT DISTINCT s.last_name, s.first_name, s.patronymic,
nd.id as declension_id,
nd.last_name_declined,
nd.first_name_declined,
nd.patronymic_declined,
nd.no_decline_last,
nd.no_decline_first,
nd.no_decline_patr,
nd.updated_at
FROM students s
LEFT JOIN name_declensions nd
ON nd.last_name = s.last_name
AND nd.first_name = s.first_name
AND nd.patronymic = s.patronymic
${wherePart}
ORDER BY s.last_name, s.first_name, s.patronymic
`, params);
res.json(rows);
});
router.put('/declensions', (req, res) => {
const { last_name, first_name, patronymic, last_name_declined, first_name_declined, patronymic_declined, no_decline_last, no_decline_first, no_decline_patr } = req.body;
if (!last_name || !first_name) {
return res.status(400).json({ error: 'Фамилия и имя обязательны' });
}
dbRun(`
INSERT INTO name_declensions (last_name, first_name, patronymic, last_name_declined, first_name_declined, patronymic_declined, no_decline_last, no_decline_first, no_decline_patr, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(last_name, first_name, patronymic) DO UPDATE SET
last_name_declined = excluded.last_name_declined,
first_name_declined = excluded.first_name_declined,
patronymic_declined = excluded.patronymic_declined,
no_decline_last = excluded.no_decline_last,
no_decline_first = excluded.no_decline_first,
no_decline_patr = excluded.no_decline_patr,
updated_at = excluded.updated_at
`, [
last_name,
first_name,
patronymic || '',
last_name_declined || '',
first_name_declined || '',
patronymic_declined || '',
no_decline_last ? 1 : 0,
no_decline_first ? 1 : 0,
no_decline_patr ? 1 : 0
]);
saveDb();
res.json({ ok: true });
});
router.delete('/data', (req, res) => {
dbRun('DELETE FROM exam_results');
dbRun('DELETE FROM students');

View File

@@ -1,5 +1,5 @@
const express = require('express');
const { dbGet, dbAll } = require('../db');
const { dbGet, dbAll, dbRun } = require('../db');
const router = express.Router();
const requireAuth = (req, res, next) => {
@@ -119,7 +119,7 @@ router.get('/certificate/:id', requireAuth, (req, res) => {
}
const gender = detectGender(student.patronymic);
const fioDative = formatNameDative(student, gender);
const fioDative = formatNameDativeWithOverrides(student, gender);
const verbEnding = gender === 'm' ? 'обучался' : 'обучалась';
const verbEnding2 = gender === 'm' ? 'получил' : 'получила';
@@ -203,6 +203,53 @@ function declineMalePatr(name) {
return name + 'у';
}
function getDeclensionOverrides(last_name, first_name, patronymic) {
return dbGet(
'SELECT * FROM name_declensions WHERE last_name = ? AND first_name = ? AND patronymic = ?',
[last_name || '', first_name || '', patronymic || '']
);
}
function formatNameDativeWithOverrides(student, gender) {
const l = student.last_name || '';
const f = student.first_name || '';
const p = student.patronymic || '';
let declinedL, declinedF, declinedP;
if (gender === 'f') {
declinedL = declineFemaleLast(l);
declinedF = declineFemaleFirst(f);
declinedP = declineFemalePatr(p);
} else {
declinedL = declineMaleLast(l);
declinedF = declineMaleFirst(f);
declinedP = declineMalePatr(p);
}
const override = getDeclensionOverrides(l, f, p);
if (override) {
if (override.no_decline_last) {
declinedL = l;
} else if (override.last_name_declined) {
declinedL = override.last_name_declined;
}
if (override.no_decline_first) {
declinedF = f;
} else if (override.first_name_declined) {
declinedF = override.first_name_declined;
}
if (override.no_decline_patr) {
declinedP = p;
} else if (override.patronymic_declined) {
declinedP = override.patronymic_declined;
}
}
return `${declinedL} ${declinedF} ${declinedP}`;
}
router.get('/batch-print', requireAuth, (req, res) => {
const { class: cls, parallel, type } = req.query;
@@ -245,7 +292,7 @@ router.get('/batch-print', requireAuth, (req, res) => {
const finalType = (type === 'gia' || type === 'gve') ? type : autoType;
const gender = detectGender(st.patronymic);
const fioDative = formatNameDative(st, gender);
const fioDative = formatNameDativeWithOverrides(st, gender);
let allSubjects;
if (finalType === 'gia') {
@@ -289,4 +336,8 @@ router.get('/history', requireAuth, (req, res) => {
res.render('history', { rows, user: req.session.user });
});
router.get('/declension', requireAuth, (req, res) => {
res.render('declension', { user: req.session.user });
});
module.exports = router;

246
views/declension.ejs Normal file
View File

@@ -0,0 +1,246 @@
<!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">
<h1>Настройки склонения ФИО</h1>
<nav class="nav">
<a href="/" class="btn">&larr; К списку</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>
<div class="card">
<p style="font-size:13px;color:#666;margin-bottom:10px">
Настройте склонение фамилии, имени и отчества для справок.
Если отмечено «Не склоняется» — ФИО будет использоваться в именительном падеже.
Если введено своё склонение — оно будет использовано вместо автоматического.
</p>
<div class="form-row">
<label>Фамилия:</label>
<input type="text" id="filter-last" class="input input-sm" placeholder="Фильтр по фамилии" oninput="loadDeclensions()">
</div>
<div class="form-row">
<label>Имя:</label>
<input type="text" id="filter-first" class="input input-sm" placeholder="Фильтр по имени" oninput="loadDeclensions()">
</div>
<div class="form-row">
<label>Отчество:</label>
<input type="text" id="filter-patr" class="input input-sm" placeholder="Фильтр по отчеству" oninput="loadDeclensions()">
</div>
</div>
<div class="card" style="overflow-x:auto">
<table id="declension-table">
<thead>
<tr>
<th>Фамилия</th>
<th>Имя</th>
<th>Отчество</th>
<th>Склонённая фамилия</th>
<th style="text-align:center">Не скл.</th>
<th>Склонённое имя</th>
<th style="text-align:center">Не скл.</th>
<th>Склонённое отчество</th>
<th style="text-align:center">Не скл.</th>
<th>Действия</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</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';
});
function esc(s) { return (s || '').replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
function autoDeclineLast(name, gender) {
if (!name) return '';
if (gender === 'f') {
if (name.endsWith('ая')) return name.slice(0, -2) + 'ой';
if (name.endsWith('яя')) return name.slice(0, -2) + 'ей';
if (name.endsWith('а')) return name.slice(0, -1) + 'ой';
if (name.endsWith('я')) return name.slice(0, -1) + 'е';
if (name.endsWith('ова') || name.endsWith('ева') || name.endsWith('ина')) return name.slice(0, -1) + 'ой';
return name + 'ой';
}
if (name.endsWith('ов') || name.endsWith('ев') || name.endsWith('ин') || name.endsWith('ёв')) return name + 'у';
if (name.endsWith('ий') || name.endsWith('ый')) return name.slice(0, -2) + 'ому';
if (name.endsWith('ой')) return name.slice(0, -2) + 'ому';
return name + 'у';
}
function autoDeclineFirst(name, gender) {
if (!name) return '';
if (gender === 'f') {
if (name.endsWith('а') || name.endsWith('я')) return name.slice(0, -1) + 'е';
return name + 'е';
}
if (name.endsWith('й') || name.endsWith('ь')) return name.slice(0, -1) + 'ю';
if (name.endsWith('а') || name.endsWith('я')) return name.slice(0, -1) + 'е';
return name + 'у';
}
function autoDeclinePatr(name, gender) {
if (!name) return '';
if (gender === 'f') {
if (name.endsWith('на')) return name.slice(0, -1) + 'е';
return name + 'е';
}
if (name.endsWith('ич')) return name + 'у';
if (name.endsWith('на')) return name.slice(0, -1) + 'е';
return name + 'у';
}
function detectGender(patronymic) {
if (!patronymic) return 'm';
const last = patronymic.toLowerCase();
if (/вна|чна|нична$/.test(last)) return 'f';
return 'm';
}
async function loadDeclensions() {
const last_name = document.getElementById('filter-last').value;
const first_name = document.getElementById('filter-first').value;
const patronymic = document.getElementById('filter-patr').value;
const params = new URLSearchParams();
if (last_name) params.set('last_name', last_name);
if (first_name) params.set('first_name', first_name);
if (patronymic) params.set('patronymic', patronymic);
const url = '/api/declensions' + (params.toString() ? '?' + params.toString() : '');
const res = await fetch(url);
const rows = await res.json();
renderTable(rows);
}
function renderTable(rows) {
const tbody = document.querySelector('#declension-table tbody');
tbody.innerHTML = rows.map((r, i) => {
const gender = detectGender(r.patronymic);
const autoLast = autoDeclineLast(r.last_name, gender);
const autoFirst = autoDeclineFirst(r.first_name, gender);
const autoPatr = autoDeclinePatr(r.patronymic, gender);
const currentLast = r.no_decline_last ? r.last_name : (r.last_name_declined || autoLast);
const currentFirst = r.no_decline_first ? r.first_name : (r.first_name_declined || autoFirst);
const currentPatr = r.no_decline_patr ? r.patronymic : (r.patronymic_declined || autoPatr);
return `
<tr data-idx="${i}">
<td>${esc(r.last_name)}</td>
<td>${esc(r.first_name)}</td>
<td>${esc(r.patronymic)}</td>
<td><input type="text" class="input input-sm decl-input" data-field="last_name_declined" data-idx="${i}" value="${esc(currentLast)}" placeholder="${esc(autoLast)}"></td>
<td style="text-align:center"><input type="checkbox" class="decl-cb" data-field="no_decline_last" data-idx="${i}" ${r.no_decline_last ? 'checked' : ''}></td>
<td><input type="text" class="input input-sm decl-input" data-field="first_name_declined" data-idx="${i}" value="${esc(currentFirst)}" placeholder="${esc(autoFirst)}"></td>
<td style="text-align:center"><input type="checkbox" class="decl-cb" data-field="no_decline_first" data-idx="${i}" ${r.no_decline_first ? 'checked' : ''}></td>
<td><input type="text" class="input input-sm decl-input" data-field="patronymic_declined" data-idx="${i}" value="${esc(currentPatr)}" placeholder="${esc(autoPatr)}"></td>
<td style="text-align:center"><input type="checkbox" class="decl-cb" data-field="no_decline_patr" data-idx="${i}" ${r.no_decline_patr ? 'checked' : ''}></td>
<td><button class="btn btn-sm btn-green" onclick="saveRow(${i})">Сохранить</button><span id="msg-${i}" class="msg"></span></td>
</tr>`;
}).join('');
window.__declensionRows = rows;
document.querySelectorAll('.decl-cb').forEach(cb => {
cb.addEventListener('change', function() {
const idx = this.dataset.idx;
const field = this.dataset.field;
const row = window.__declensionRows[idx];
const inputField = field.replace('no_decline_', '') + '_declined';
const inputEl = document.querySelector(`.decl-input[data-idx="${idx}"][data-field="${inputField}"]`);
if (this.checked) {
inputEl.value = field === 'no_decline_last' ? row.last_name : (field === 'no_decline_first' ? row.first_name : row.patronymic);
inputEl.disabled = true;
} else {
inputEl.disabled = false;
}
});
});
document.querySelectorAll('.decl-cb:checked').forEach(cb => {
const idx = cb.dataset.idx;
const field = cb.dataset.field;
const inputField = field.replace('no_decline_', '') + '_declined';
const inputEl = document.querySelector(`.decl-input[data-idx="${idx}"][data-field="${inputField}"]`);
if (inputEl) inputEl.disabled = true;
});
}
async function saveRow(idx) {
const row = window.__declensionRows[idx];
const lastInput = document.querySelector(`.decl-input[data-idx="${idx}"][data-field="last_name_declined"]`);
const firstInput = document.querySelector(`.decl-input[data-idx="${idx}"][data-field="first_name_declined"]`);
const patrInput = document.querySelector(`.decl-input[data-idx="${idx}"][data-field="patronymic_declined"]`);
const lastCb = document.querySelector(`.decl-cb[data-idx="${idx}"][data-field="no_decline_last"]`);
const firstCb = document.querySelector(`.decl-cb[data-idx="${idx}"][data-field="no_decline_first"]`);
const patrCb = document.querySelector(`.decl-cb[data-idx="${idx}"][data-field="no_decline_patr"]`);
const msgEl = document.getElementById(`msg-${idx}`);
const body = {
last_name: row.last_name,
first_name: row.first_name,
patronymic: row.patronymic,
last_name_declined: lastCb.checked ? '' : lastInput.value,
first_name_declined: firstCb.checked ? '' : firstInput.value,
patronymic_declined: patrCb.checked ? '' : patrInput.value,
no_decline_last: lastCb.checked,
no_decline_first: firstCb.checked,
no_decline_patr: patrCb.checked
};
try {
const res = await fetch('/api/declensions', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json();
if (res.ok) {
msgEl.textContent = 'Сохранено';
msgEl.className = 'msg msg-ok';
loadDeclensions();
} else {
msgEl.textContent = data.error || 'Ошибка';
msgEl.className = 'msg msg-err';
}
} catch (e) {
msgEl.textContent = 'Ошибка сети';
msgEl.className = 'msg msg-err';
}
}
loadDeclensions();
</script>
</body>
</html>

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="/declension" class="btn" style="background:#2a7d5a">Склонение ФИО</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>