Склонение ФИО
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
24
src/db.js
24
src/db.js
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
246
views/declension.ejs
Normal 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">← К списку</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 => ({'&':'&','<':'<','>':'>','"':'"'}[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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user