This commit is contained in:
2026-07-01 09:18:06 +05:00
commit 0fc7be87b5
16 changed files with 2108 additions and 0 deletions

8
.env.example Normal file
View File

@@ -0,0 +1,8 @@
SESSION_SECRET=change_me_to_a_random_string
LDAP_AUTH_URL=https://ldap.itschool25.ru/api/auth
PORT=3000
USER_1_LOGIN=admin
USER_1_PASSWORD=changeme
USER_1_NAME=Administrator
USER_1_EMAIL=admin@example.com
USER_1_ROLE=admin

140
.gitignore vendored Normal file
View File

@@ -0,0 +1,140 @@
INSTRUCTIONS-hourwork-s1.md
mcp-memory.json
report.md
# ---> Node
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
opencode.json
package-lock.json
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
data
promt
public/test-results.json

22
package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "diplom-certificates",
"version": "1.1.0",
"description": "Сервис для формирования справок ГИА и ГВЭ",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"dependencies": {
"bcryptjs": "~2.4.3",
"connect-sqlite3": "^0.9.16",
"dotenv": "~16.3.1",
"ejs": "^3.1.10",
"express": "^4.21.0",
"express-session": "^1.18.2",
"multer": "^1.4.5-lts.1",
"node-fetch": "~2.6.7",
"sql.js": "^1.10.0",
"xlsx": "^0.18.5"
}
}

88
public/style.css Normal file
View File

@@ -0,0 +1,88 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 15px;
background: #f5f5f5;
color: #333;
line-height: 1.5;
}
.container { max-width: 960px; margin: 0 auto; padding: 20px; }
h1 { margin-bottom: 15px; }
h2 { margin: 15px 0 8px; font-size: 16px; }
.nav { margin-bottom: 15px; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.btn {
display: inline-block;
padding: 7px 14px;
background: #555;
color: #fff;
text-decoration: none;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.btn:hover { opacity: 0.85; }
.btn-green { background: #2a7d2a; }
.btn-blue { background: #0066cc; }
.btn-danger { background: #c0392b; }
.btn-sm { padding: 3px 10px; font-size: 13px; }
.btn-logout { background: #666; }
.input {
padding: 8px 10px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
width: 100%;
max-width: 300px;
}
.input-sm { max-width: 120px; }
.search-bar { margin-bottom: 15px; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 4px; overflow: hidden; }
table th, table td { padding: 8px 10px; text-align: left; border-bottom: 1px solid #eee; }
table th { background: #eee; font-weight: 600; }
table tr:hover { background: #fafafa; }
.card {
background: #fff;
padding: 15px 20px;
border-radius: 6px;
margin-bottom: 15px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.form-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.form-row label { min-width: 180px; font-weight: 500; }
.checkbox-label {
display: block;
margin: 3px 0;
cursor: pointer;
}
.msg { font-size: 13px; margin-left: 10px; }
.msg-ok { color: #2a7d2a; }
.msg-err { color: #c0392b; }
.toolbar { margin-top: 15px; }
ol { padding-left: 20px; }
ol li { margin: 4px 0; }
.user-bar {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: #666;
}

151
server.js Normal file
View File

@@ -0,0 +1,151 @@
require('dotenv').config();
const express = require('express');
const crypto = require('crypto');
const path = require('path');
const session = require('express-session');
const SQLiteStore = require('connect-sqlite3')(session);
const { initDb, getDb } = require('./src/db');
const authService = require('./src/auth');
const app = express();
const PORT = process.env.PORT || 3000;
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, 'public')));
app.use(session({
store: new SQLiteStore({
db: 'sessions.db',
dir: path.join(__dirname, 'data'),
table: 'sessions'
}),
secret: process.env.SESSION_SECRET || 'fallback_secret_change_in_production',
resave: false,
saveUninitialized: false,
cookie: {
secure: false,
maxAge: 7 * 24 * 60 * 60 * 1000,
httpOnly: true,
sameSite: 'lax'
}
}));
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.use((req, res, next) => {
res.locals.csrfToken = req.session ? req.session.csrfToken || '' : '';
res.locals.user = req.session ? req.session.user || null : null;
next();
});
app.use((req, res, next) => {
const mutating = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method);
const isLoginPath = req.path === '/api/login';
if (!isLoginPath && req.session && req.session.user && !req.session.csrfToken) {
req.session.csrfToken = crypto.randomBytes(32).toString('hex');
}
if (mutating) {
if (isLoginPath) return next();
if (!req.session || !req.session.user) {
return res.status(401).json({ error: 'Требуется аутентификация' });
}
const clientToken = req.headers['x-csrf-token'] || req.headers['csrf-token'];
if (!req.session.csrfToken) {
req.session.csrfToken = crypto.randomBytes(32).toString('hex');
}
if (!clientToken || clientToken !== req.session.csrfToken) {
return res.status(403).json({ error: 'CSRF token missing or invalid' });
}
}
next();
});
const requireAuth = (req, res, next) => {
if (!req.session.user) {
if (req.headers.accept && req.headers.accept.includes('application/json')) {
return res.status(401).json({ error: 'Требуется аутентификация' });
}
return res.redirect('/login');
}
next();
};
app.post('/api/login', async (req, res) => {
const { login, password } = req.body;
if (!login || !password) {
return res.status(400).json({ error: 'Логин и пароль обязательны' });
}
try {
const user = await authService.authenticate(login, password);
if (user) {
const sessionUser = {
id: user.id,
login: user.login,
name: user.name,
email: user.email,
role: user.role,
auth_type: user.auth_type,
groups: user.groups ? (typeof user.groups === 'string' ? JSON.parse(user.groups) : user.groups) : []
};
if (sessionUser.login === process.env.USER_1_LOGIN) {
sessionUser.role = 'admin';
}
req.session.user = sessionUser;
req.session.csrfToken = crypto.randomBytes(32).toString('hex');
req.session.save((err) => {
if (err) return res.status(500).json({ error: 'Ошибка сохранения сессии' });
console.log(`Вход: ${user.name} (${user.login})`);
res.json({ success: true, user: sessionUser, csrfToken: req.session.csrfToken });
});
} else {
res.status(401).json({ error: 'Неверный логин или пароль' });
}
} catch (err) {
console.error('Ошибка аутентификации:', err.message);
res.status(500).json({ error: 'Ошибка сервера при авторизации' });
}
});
app.post('/api/logout', (req, res) => {
req.session.destroy((err) => {
if (err) return res.status(500).json({ error: 'Ошибка при выходе' });
res.json({ success: true });
});
});
app.get('/api/user', (req, res) => {
if (req.session.user) {
if (!req.session.csrfToken) {
req.session.csrfToken = crypto.randomBytes(32).toString('hex');
}
return res.json({ success: true, user: req.session.user, csrfToken: req.session.csrfToken });
}
res.status(401).json({ error: 'Не авторизован' });
});
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
async function start() {
await initDb();
authService.setDatabase(getDb());
const apiRoutes = require('./src/routes/api');
const pageRoutes = require('./src/routes/pages');
app.use('/api', apiRoutes);
app.use('/', pageRoutes);
app.listen(PORT, () => {
console.log(`Сервер запущен: http://localhost:${PORT}`);
});
}
start().catch(err => {
console.error('Ошибка запуска:', err);
process.exit(1);
});

121
src/auth.js Normal file
View File

@@ -0,0 +1,121 @@
const bcrypt = require('bcryptjs');
const { dbGet, dbRun, dbLastInsertId, saveDb } = require('./db');
class AuthService {
constructor() {
this.db = null;
}
setDatabase(db) {
this.db = db;
this.initUsers();
}
initUsers() {
if (!this.db) return;
try {
const login = process.env.USER_1_LOGIN;
const pass = process.env.USER_1_PASSWORD;
if (login && pass) {
this.createUserIfNotExists({
login, password: pass,
name: process.env.USER_1_NAME,
email: process.env.USER_1_EMAIL,
role: process.env.USER_1_ROLE || 'admin'
});
}
} catch (err) {
console.error('Ошибка инициализации пользователей:', err.message);
}
}
createUserIfNotExists(userData) {
const row = dbGet('SELECT id FROM users WHERE login = ?', [userData.login]);
if (row) return row.id;
const hash = bcrypt.hashSync(userData.password, 10);
dbRun(
"INSERT INTO users (login, password, name, email, role, auth_type, created_at) VALUES (?, ?, ?, ?, ?, 'local', datetime('now'))",
[userData.login, hash, userData.name, userData.email, userData.role || 'teacher']
);
const id = dbLastInsertId();
console.log(`Создан пользователь: ${userData.name}`);
saveDb();
return id;
}
authenticateLocal(login, password) {
const user = dbGet("SELECT * FROM users WHERE login = ? AND auth_type = 'local'", [login]);
if (!user) return null;
if (!user.password) return null;
if (!bcrypt.compareSync(password, user.password)) return null;
dbRun("UPDATE users SET last_login = datetime('now') WHERE id = ?", [user.id]);
saveDb();
const { password: _, ...rest } = user;
return rest;
}
async authenticateLDAP(username, password) {
if (!process.env.LDAP_AUTH_URL) return null;
try {
const fetch = require('node-fetch');
const response = await fetch(process.env.LDAP_AUTH_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (!response.ok) return null;
const data = await response.json();
if (!data.success) return null;
return this.processLDAPUser(data);
} catch (err) {
console.error('Ошибка LDAP:', err.message);
return null;
}
}
processLDAPUser(ldapData) {
const { username, full_name, groups } = ldapData;
const groupsArr = groups || [];
const existing = dbGet("SELECT * FROM users WHERE login = ? AND auth_type = 'ldap'", [username]);
const userData = {
login: username,
name: full_name || username,
email: `${username}@school25.ru`,
groups: JSON.stringify(groupsArr)
};
if (existing) {
dbRun(
"UPDATE users SET name=?, email=?, groups=?, last_login=datetime('now') WHERE id=?",
[userData.name, userData.email, userData.groups, existing.id]
);
saveDb();
const { password: _, ...rest } = existing;
return { ...rest, name: userData.name, email: userData.email, groups: userData.groups };
}
dbRun(
"INSERT INTO users (login, name, email, auth_type, groups, created_at, last_login) VALUES (?, ?, ?, 'ldap', ?, datetime('now'), datetime('now'))",
[username, userData.name, userData.email, userData.groups]
);
const id = dbLastInsertId();
console.log(`Создан LDAP пользователь: ${username}`);
saveDb();
return { id, login: username, name: userData.name, email: userData.email, auth_type: 'ldap', groups: userData.groups };
}
async authenticate(login, password) {
const localUser = this.authenticateLocal(login, password);
if (localUser) return localUser;
return this.authenticateLDAP(login, password);
}
isReady() { return this.db !== null; }
}
const authService = new AuthService();
module.exports = authService;

139
src/db.js Normal file
View File

@@ -0,0 +1,139 @@
const initSqlJs = require('sql.js');
const path = require('path');
const fs = require('fs');
const DATA_DIR = path.join(__dirname, '..', 'data');
const DB_PATH = path.join(DATA_DIR, 'exams.db');
let db = null;
async function initDb() {
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
const SQL = await initSqlJs();
if (fs.existsSync(DB_PATH)) {
const buffer = fs.readFileSync(DB_PATH);
db = new SQL.Database(buffer);
} else {
db = new SQL.Database();
}
db.run('PRAGMA foreign_keys = ON');
db.run(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
login TEXT UNIQUE NOT NULL,
password TEXT,
name TEXT NOT NULL,
email TEXT,
role TEXT DEFAULT 'teacher',
auth_type TEXT DEFAULT 'local',
groups TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS students (
id INTEGER PRIMARY KEY AUTOINCREMENT,
last_name TEXT NOT NULL,
first_name TEXT NOT NULL,
patronymic TEXT DEFAULT '',
class TEXT DEFAULT '',
school_code TEXT DEFAULT '',
passport_series TEXT DEFAULT '',
passport_number TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(last_name, first_name, patronymic, class)
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS subjects (
code TEXT PRIMARY KEY,
name TEXT NOT NULL,
exam_date TEXT DEFAULT '',
is_mandatory INTEGER DEFAULT 0
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS exam_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
student_id INTEGER NOT NULL,
subject_code TEXT NOT NULL,
primary_score INTEGER,
grade INTEGER,
FOREIGN KEY (student_id) REFERENCES students(id) ON DELETE CASCADE,
FOREIGN KEY (subject_code) REFERENCES subjects(code),
UNIQUE(student_id, subject_code)
)
`);
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', 'Математика']);
saveDb();
console.log('БД инициализирована');
return db;
}
function saveDb() {
if (!db) return;
const data = db.export();
const buffer = Buffer.from(data);
fs.writeFileSync(DB_PATH, buffer);
}
function getDb() {
if (!db) throw new Error('БД не инициализирована.');
return db;
}
// Wrappers emulating better-sqlite3 API
function dbGet(sql, params = []) {
const st = db.prepare(sql);
st.bind(params);
const hasRow = st.step();
const row = hasRow ? st.getAsObject() : null;
st.free();
return row;
}
function dbAll(sql, params = []) {
const st = db.prepare(sql);
st.bind(params);
const rows = [];
while (st.step()) {
rows.push(st.getAsObject());
}
st.free();
return rows;
}
function dbRun(sql, params = []) {
const before = db.getRowsModified();
db.run(sql, params);
const after = db.getRowsModified();
return { changes: after - before };
}
function dbLastInsertId() {
const r = db.exec('SELECT last_insert_rowid() as id');
return r.length ? r[0].values[0][0] : null;
}
function dbExec(sql) {
db.run(sql);
}
function dbBegin() { db.run('BEGIN'); }
function dbCommit() { db.run('COMMIT'); }
function dbRollback() { try { db.run('ROLLBACK'); } catch (e) {} }
module.exports = { initDb, getDb, saveDb, dbGet, dbAll, dbRun, dbLastInsertId, dbExec, dbBegin, dbCommit, dbRollback };

170
src/parser.js Normal file
View File

@@ -0,0 +1,170 @@
const XLSX = require('xlsx');
const SUBJECT_NAMES = [
'Русский язык', 'Математика', 'Обществознание', 'Физика',
'Химия', 'Информатика', 'Биология', 'Английский язык',
'География', 'Литература', 'История', 'Немецкий язык',
'Французский язык', 'Испанский язык'
];
const HEADER_MAP = {
'фамилия': 'last_name',
'имя': 'first_name',
'отчество': 'patronymic',
'класс': 'class',
'код оо': 'school_code',
'серия': 'passport_series',
'номер': 'passport_number',
'первичный балл': 'primary_score',
'оценка': 'grade'
};
function normalize(s) {
return String(s || '').trim().toLowerCase();
}
function parseXls(filePath) {
const workbook = XLSX.readFile(filePath);
const sheetName = workbook.SheetNames[0];
const sheet = workbook.Sheets[sheetName];
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '' });
const parsedSubjects = [];
const studentsMap = new Map();
const results = [];
let currentSubject = null;
let colIdx = null;
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (!row || row.every(c => c === '' || c === null || c === undefined)) continue;
const firstCell = String(row[0] || '').trim();
if (isSummaryRowText(firstCell)) {
currentSubject = null;
colIdx = null;
continue;
}
const subjectInfo = detectSubject(firstCell);
if (subjectInfo) {
currentSubject = subjectInfo;
parsedSubjects.push(subjectInfo);
colIdx = null;
continue;
}
if (currentSubject && !colIdx) {
colIdx = findColumnHeaders(row);
if (colIdx) continue;
}
if (currentSubject && colIdx) {
const sd = extractStudentData(row, colIdx);
if (sd && sd.last_name) {
const key = `${normalize(sd.last_name)}|${normalize(sd.first_name)}|${normalize(sd.patronymic)}|${normalize(sd.class)}`;
if (!studentsMap.has(key)) {
studentsMap.set(key, {
last_name: sd.last_name,
first_name: sd.first_name,
patronymic: sd.patronymic,
class: sd.class,
school_code: sd.school_code || '',
passport_series: sd.passport_series || '',
passport_number: sd.passport_number || ''
});
}
results.push({
student_key: key,
subject_code: currentSubject.code,
subject_name: currentSubject.name,
exam_date: currentSubject.date || '',
primary_score: sd.primary_score,
grade: sd.grade
});
}
}
}
return {
subjects: uniqSubjects(parsedSubjects),
students: [...studentsMap.values()],
results
};
}
function uniqSubjects(subjects) {
const seen = new Set();
return subjects.filter(s => {
if (seen.has(s.code)) return false;
seen.add(s.code);
return true;
});
}
function detectSubject(firstCell) {
if (!firstCell) return null;
const match = firstCell.match(/^(\d{2})\s*-\s*(.+?)\s+(\d{4}\.\d{2}\.\d{2})$/);
if (!match) return null;
const code = match[1];
const name = match[2];
const date = match[3];
if (SUBJECT_NAMES.some(sn => sn.toLowerCase() === name.toLowerCase())) {
return { code, name, date };
}
return null;
}
function isSummaryRowText(firstCell) {
return /^(средни|всего|итого)/i.test(firstCell);
}
function findColumnHeaders(row) {
const map = {};
for (let i = 0; i < row.length; i++) {
const s = normalize(String(row[i]));
for (const [pattern, field] of Object.entries(HEADER_MAP)) {
if (s === pattern || s.includes(pattern)) {
map[field] = i;
break;
}
}
}
if (map.last_name && (map.primary_score || map.grade)) return map;
return null;
}
function extractStudentData(row, colIdx) {
const get = (field) => {
const idx = colIdx[field];
if (idx === undefined || idx >= row.length) return '';
return String(row[idx]).trim();
};
const firstCol = String(row[0] || '').trim();
if (!firstCol || !/^\d+$/.test(firstCol)) return null;
const last_name = get('last_name');
if (!last_name || last_name.length < 2) return null;
const toInt = (v) => {
const n = parseInt(v, 10);
return isNaN(n) ? null : n;
};
return {
last_name,
first_name: get('first_name'),
patronymic: get('patronymic'),
class: get('class'),
school_code: get('school_code'),
passport_series: get('passport_series'),
passport_number: get('passport_number'),
primary_score: toInt(get('primary_score')),
grade: toInt(get('grade'))
};
}
module.exports = { parseXls };

145
src/routes/api.js Normal file
View File

@@ -0,0 +1,145 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { getDb, saveDb, dbGet, dbAll, dbRun, dbLastInsertId, dbBegin, dbCommit, dbRollback } = require('../db');
const { parseXls } = require('../parser');
const router = express.Router();
const UPLOAD_DIR = path.join(__dirname, '..', '..', 'data', 'uploads');
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
const upload = multer({
dest: UPLOAD_DIR,
fileFilter: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (ext === '.xls' || ext === '.xlsx') cb(null, true);
else cb(new Error('Только .xls и .xlsx файлы'));
}
});
router.post('/import', upload.single('xlsfile'), (req, res) => {
try {
if (!req.file) return res.status(400).json({ error: 'Файл не загружен' });
const parsed = parseXls(req.file.path);
const studentIdByKey = {};
dbBegin();
for (const subj of parsed.subjects) {
dbRun('INSERT OR IGNORE INTO subjects (code, name, exam_date) VALUES (?, ?, ?)', [subj.code, subj.name, subj.date || '']);
}
for (const st of parsed.students) {
const key = `${st.last_name}|${st.first_name}|${st.patronymic}|${st.class}`.toLowerCase();
const before = dbRun('INSERT OR IGNORE INTO students (last_name, first_name, patronymic, class, school_code, passport_series, passport_number) VALUES (?, ?, ?, ?, ?, ?, ?)', [st.last_name, st.first_name, st.patronymic, st.class, st.school_code, st.passport_series, st.passport_number]);
if (before.changes > 0) {
studentIdByKey[key] = dbLastInsertId();
} else {
const row = dbGet('SELECT id FROM students WHERE last_name=? AND first_name=? AND patronymic=? AND class=?', [st.last_name, st.first_name, st.patronymic, st.class]);
if (row) {
studentIdByKey[key] = row.id;
dbRun('UPDATE students SET school_code=?, passport_series=?, passport_number=? WHERE id=?', [st.school_code, st.passport_series, st.passport_number, row.id]);
}
}
}
for (const r of parsed.results) {
const sid = studentIdByKey[r.student_key.toLowerCase()];
if (sid) {
dbRun('INSERT OR REPLACE INTO exam_results (student_id, subject_code, primary_score, grade) VALUES (?, ?, ?, ?)', [sid, r.subject_code, r.primary_score, r.grade]);
}
}
dbCommit();
saveDb();
fs.unlink(req.file.path, () => {});
res.json({
ok: true,
students: parsed.students.length,
subjects: parsed.subjects.length,
results: parsed.results.length
});
} catch (err) {
dbRollback();
console.error(err);
if (req.file) fs.unlink(req.file.path, () => {});
res.status(500).json({ error: err.message });
}
});
router.get('/students', (req, res) => {
const search = req.query.search || '';
let rows;
if (search) {
const like = `%${search}%`;
rows = dbAll(`
SELECT s.*, COUNT(er.id) as exam_count
FROM students s
LEFT JOIN exam_results er ON er.student_id = s.id
WHERE s.last_name LIKE ? OR s.first_name LIKE ? OR s.patronymic LIKE ? OR s.class LIKE ?
GROUP BY s.id
ORDER BY s.class, s.last_name, s.first_name
`, [like, like, like, like]);
} else {
rows = dbAll(`
SELECT s.*, COUNT(er.id) as exam_count
FROM students s
LEFT JOIN exam_results er ON er.student_id = s.id
GROUP BY s.id
ORDER BY s.class, s.last_name, s.first_name
`);
}
res.json(rows);
});
router.get('/students/:id', (req, res) => {
const student = dbGet('SELECT * FROM students WHERE id = ?', [req.params.id]);
if (!student) return res.status(404).json({ error: 'Ученик не найден' });
const results = dbAll(`
SELECT er.*, s.name as subject_name, s.is_mandatory
FROM exam_results er
JOIN subjects s ON s.code = er.subject_code
WHERE er.student_id = ?
ORDER BY s.is_mandatory DESC, s.name
`, [req.params.id]);
res.json({ student, results });
});
router.put('/students/:id/mandatory', (req, res) => {
const { id } = req.params;
const { rus_score, rus_grade, math_score, math_grade } = req.body;
dbBegin();
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]);
}
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]);
}
dbCommit();
saveDb();
res.json({ ok: true });
});
router.get('/classes', (req, res) => {
const rows = dbAll('SELECT DISTINCT class FROM students ORDER BY class');
res.json(rows.map(r => r.class));
});
router.delete('/data', (req, res) => {
dbRun('DELETE FROM exam_results');
dbRun('DELETE FROM students');
dbRun("DELETE FROM subjects WHERE is_mandatory = 0");
saveDb();
res.json({ ok: true });
});
module.exports = router;

230
src/routes/pages.js Normal file
View File

@@ -0,0 +1,230 @@
const express = require('express');
const { dbGet, dbAll } = require('../db');
const router = express.Router();
const requireAuth = (req, res, next) => {
if (!req.session.user) return res.redirect('/login');
next();
};
router.get('/login', (req, res) => {
if (req.session.user) return res.redirect('/');
res.render('login');
});
router.get('/', requireAuth, (req, res) => {
res.render('index', { user: req.session.user });
});
router.get('/student/:id', requireAuth, (req, res) => {
const student = dbGet('SELECT * FROM students WHERE id = ?', [req.params.id]);
if (!student) return res.status(404).send('Ученик не найден');
const results = dbAll(`
SELECT er.*, s.name as subject_name, s.is_mandatory, s.code as subject_code
FROM exam_results er
JOIN subjects s ON s.code = er.subject_code
WHERE er.student_id = ?
ORDER BY s.is_mandatory DESC, s.name
`, [req.params.id]);
const mandatoryResults = results.filter(r => r.is_mandatory);
const electiveResults = results.filter(r => !r.is_mandatory);
const rusResult = mandatoryResults.find(r => r.subject_code === '01') || null;
const mathResult = mandatoryResults.find(r => r.subject_code === '02') || null;
res.render('student', {
student,
electiveResults,
rusResult,
mathResult,
user: req.session.user
});
});
router.get('/import', requireAuth, (req, res) => {
res.render('import', { user: req.session.user });
});
router.get('/certificate/:id', requireAuth, (req, res) => {
const { id } = req.params;
const type = req.query.type || 'gia';
const subjectCodes = req.query.subjects ? req.query.subjects.split(',') : [];
const student = dbGet('SELECT * FROM students WHERE id = ?', [id]);
if (!student) return res.status(404).send('Ученик не найден');
const rusResult = dbGet(`
SELECT er.*, s.name as subject_name
FROM exam_results er JOIN subjects s ON s.code = er.subject_code
WHERE er.student_id = ? AND er.subject_code = '01'
`, [id]);
const mathResult = dbGet(`
SELECT er.*, s.name as subject_name
FROM exam_results er JOIN subjects s ON s.code = er.subject_code
WHERE er.student_id = ? AND er.subject_code = '02'
`, [id]);
let electives = [];
if (type === 'gia' && subjectCodes.length > 0) {
const placeholders = subjectCodes.map(() => '?').join(',');
electives = dbAll(`
SELECT er.*, s.name as subject_name
FROM exam_results er JOIN subjects s ON s.code = er.subject_code
WHERE er.student_id = ? AND er.subject_code IN (${placeholders})
`, [id, ...subjectCodes]);
}
const today = new Date();
const certNumber = `${today.getFullYear()}${String(id).padStart(4, '0')}`;
const months = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'];
const dateStr = `«${String(today.getDate()).padStart(2, '0')}» ${months[today.getMonth()]} ${today.getFullYear()} года`;
const gender = detectGender(student.patronymic);
const fioDative = formatNameDative(student, gender);
const verbEnding = gender === 'm' ? 'обучался' : 'обучалась';
const verbEnding2 = gender === 'm' ? 'получил' : 'получила';
res.render('certificate', {
type,
student: { ...student, fioDative, gender },
certNumber,
dateStr,
verbEnding,
verbEnding2,
rusResult: rusResult || null,
mathResult: mathResult || null,
electives,
allSubjects: type === 'gia'
? [
rusResult || { subject_name: 'Русский язык', primary_score: null, grade: null },
mathResult || { subject_name: 'Математика', primary_score: null, grade: null },
...electives
]
: [
rusResult || { subject_name: 'Русский язык', grade: null },
mathResult || { subject_name: 'Математика', grade: null }
]
});
});
function detectGender(patronymic) {
if (!patronymic) return 'm';
const last = patronymic.toLowerCase();
if (/вна|чна|нична$/.test(last)) return 'f';
return 'm';
}
function formatNameDative(student, gender) {
const l = student.last_name || '';
const f = student.first_name || '';
const p = student.patronymic || '';
if (gender === 'f') {
return `${declineFemaleLast(l)} ${declineFemaleFirst(f)} ${declineFemalePatr(p)}`;
}
return `${declineMaleLast(l)} ${declineMaleFirst(f)} ${declineMalePatr(p)}`;
}
function declineFemaleLast(name) {
if (!name) return '';
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 + 'ой';
}
function declineFemaleFirst(name) {
if (!name) return '';
if (name.endsWith('а') || name.endsWith('я')) return name.slice(0, -1) + 'е';
return name + 'е';
}
function declineFemalePatr(name) {
if (!name) return '';
if (name.endsWith('на')) return name.slice(0, -1) + 'е';
return name + 'е';
}
function declineMaleLast(name) {
if (!name) return '';
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 declineMaleFirst(name) {
if (!name) return '';
if (name.endsWith('й') || name.endsWith('ь')) return name.slice(0, -1) + 'ю';
if (name.endsWith('а') || name.endsWith('я')) return name.slice(0, -1) + 'е';
return name + 'у';
}
function declineMalePatr(name) {
if (!name) return '';
if (name.endsWith('ич')) return name + 'у';
if (name.endsWith('на')) return name.slice(0, -1) + 'е';
return name + 'у';
}
router.get('/batch-print', requireAuth, (req, res) => {
const { class: cls, parallel, type } = req.query;
if (!type || !['gia', 'gve'].includes(type)) return res.status(400).send('Укажите type=gia или type=gve');
let students;
if (cls) {
students = dbAll('SELECT * FROM students WHERE class = ? ORDER BY last_name, first_name', [cls]);
} else if (parallel) {
students = dbAll('SELECT * FROM students WHERE class LIKE ? ORDER BY class, last_name, first_name', [parallel + '%']);
} else {
students = dbAll('SELECT * FROM students ORDER BY class, last_name, first_name');
}
const today = new Date();
const months = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'];
const dateStr = `«${String(today.getDate()).padStart(2, '0')}» ${months[today.getMonth()]} ${today.getFullYear()} года`;
const studentData = students.map(st => {
const results = dbAll(`
SELECT er.*, s.name as subject_name, s.is_mandatory
FROM exam_results er JOIN subjects s ON s.code = er.subject_code
WHERE er.student_id = ?
`, [st.id]);
const rusResult = results.find(r => r.subject_code === '01') || null;
const mathResult = results.find(r => r.subject_code === '02') || null;
const electives = results.filter(r => !r.is_mandatory);
const gender = detectGender(st.patronymic);
const fioDative = formatNameDative(st, gender);
let allSubjects;
if (type === 'gia') {
allSubjects = [
rusResult || { subject_name: 'Русский язык', primary_score: null, grade: null },
mathResult || { subject_name: 'Математика', primary_score: null, grade: null },
...electives
];
} else {
allSubjects = [
rusResult || { subject_name: 'Русский язык', grade: null },
mathResult || { subject_name: 'Математика', grade: null }
];
}
return {
student: { ...st, fioDative, gender },
certNumber: `${today.getFullYear()}${String(st.id).padStart(4, '0')}`,
dateStr,
verbEnding: gender === 'm' ? 'обучался' : 'обучалась',
verbEnding2: gender === 'm' ? 'получил' : 'получила',
allSubjects
};
});
res.render('batch-certificate', { type, students: studentData });
});
module.exports = router;

172
views/batch-certificate.ejs Normal file
View File

@@ -0,0 +1,172 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Справки — <%= type === 'gia' ? 'ГИА' : 'ГВЭ' %> (пакет)</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Times New Roman', Times, serif;
font-size: 12pt;
line-height: 1.4;
color: #000;
margin: 0;
padding: 0;
}
.cert-page {
max-width: 210mm;
margin: 0 auto;
padding: 10mm 15mm;
page-break-after: always;
}
.cert-page:last-child { page-break-after: auto; }
.header { text-align: center; margin-bottom: 6mm; }
.header .dept { font-size: 10pt; font-weight: bold; }
.header .school {
font-size: 11pt;
font-weight: bold;
text-transform: uppercase;
margin-top: 3mm;
}
.header .address { font-size: 10pt; margin-top: 1mm; }
.title {
text-align: center;
font-weight: bold;
font-size: 12pt;
margin: 6mm 0 4mm;
text-transform: uppercase;
}
.subtitle { text-align: center; font-size: 11pt; margin-bottom: 3mm; }
.date { text-align: right; margin-bottom: 3mm; }
.body-text { text-indent: 8mm; margin-bottom: 4mm; text-align: justify; }
table {
width: 100%;
border-collapse: collapse;
margin: 3mm 0;
}
table th, table td {
border: 1px solid #000;
padding: 2mm;
text-align: center;
font-size: 10pt;
}
table th { font-weight: bold; background: #f0f0f0; }
table td:first-child { width: 8mm; }
table td:nth-child(2) { text-align: left; }
.signature {
margin-top: 10mm;
text-align: right;
}
.signature .dir { margin-bottom: 6mm; }
.signature .name { font-weight: bold; }
@media print {
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.cert-page { padding: 8mm 12mm; }
.no-print { display: none !important; }
@page { size: A4; margin: 8mm; }
}
.no-print {
position: fixed;
top: 8px;
right: 8px;
z-index: 1000;
}
.btn-print {
padding: 6px 16px;
font-size: 11pt;
background: #0066cc;
color: #fff;
border: none;
border-radius: 3px;
cursor: pointer;
}
</style>
</head>
<body>
<div class="no-print" style="display:flex; gap:8px; align-items:center; background:#fff; padding:6px 10px; border-radius:4px; box-shadow:0 1px 4px rgba(0,0,0,0.2)">
<button class="btn-print" onclick="window.print()">Печать</button>
<span style="font-size:11pt; color:#555"><%= students.length %> справок</span>
</div>
<% students.forEach((data) => { %>
<div class="cert-page">
<div class="header">
<div class="dept">ДЕПАРТАМЕНТ ОБРАЗОВАНИЯ АДМИНИСТРАЦИИ ГОРОДА ЕКАТЕРИНБУРГА</div>
<div class="dept">УПРАВЛЕНИЕ ОБРАЗОВАНИЯ АКАДЕМИЧЕСКОГО РАЙОНА</div>
<div class="school">
МУНИЦИПАЛЬНОЕ АВТОНОМНОЕ ОБЩЕОБРАЗОВАТЕЛЬНОЕ УЧРЕЖДЕНИЕ —<br>
СРЕДНЯЯ ОБЩЕОБРАЗОВАТЕЛЬНАЯ ШКОЛА № 25 им. В.Г. ФЕОФАНОВА
</div>
<div class="address">ул. Цветоносная, 2, тел. 334-32-26</div>
</div>
<div class="title">
СПРАВКА № <%= data.certNumber %><br>
о результатах государственной итоговой аттестации
</div>
<div class="subtitle">
<% if (type === 'gia') { %>
в форме основного государственного экзамена
<% } else { %>
в форме государственного выпускного экзамена
<% } %>
</div>
<div class="date">Дата выдачи <%= data.dateStr %></div>
<div class="body-text">
Настоящая справка выдана <strong><%= data.student.fioDative %></strong>
о том, что он(а) <%= data.verbEnding %>
в Муниципальном автономном общеобразовательном учреждении — средней общеобразовательной
школе № 25 имени В.Г. Феофанова по основной образовательной программе основного общего
образования и по результатам государственной итоговой аттестации
<%= data.verbEnding2 %> в 2025/2026 учебном году по учебным предметам следующие отметки
<% if (type === 'gia') { %>(количество первичных баллов)<% } %>:
</div>
<table>
<thead>
<tr>
<th>№</th>
<th>Наименование учебных предметов</th>
<% if (type === 'gia') { %>
<th>Количество первичных баллов</th>
<% } %>
<th>Отметка по пятибалльной шкале</th>
</tr>
</thead>
<tbody>
<% data.allSubjects.forEach((s, i) => { %>
<tr>
<td><%= i + 1 %></td>
<td><%= s.subject_name %></td>
<% if (type === 'gia') { %>
<td><%= s.primary_score !== null ? s.primary_score : '—' %></td>
<% } %>
<td><%= s.grade !== null ? s.grade + ' (' + gradeText(s.grade) + ')' : '—' %></td>
</tr>
<% }) %>
</tbody>
</table>
<div class="signature">
<div class="dir">Директор</div>
<div class="name">О.А. Добычина</div>
</div>
</div>
<% }) %>
<%
function gradeText(g) {
const map = { 5: 'отлично', 4: 'хорошо', 3: 'удовлетворительно', 2: 'неудовлетворительно' };
return map[g] || '';
}
%>
<script>
window.onload = () => setTimeout(() => window.print(), 500);
</script>
</body>
</html>

159
views/certificate.ejs Normal file
View File

@@ -0,0 +1,159 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Справка №<%= certNumber %></title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Times New Roman', Times, serif;
font-size: 14pt;
line-height: 1.5;
color: #000;
max-width: 210mm;
margin: 0 auto;
padding: 15mm 20mm;
}
.header { text-align: center; margin-bottom: 10mm; }
.header .dept { font-size: 11pt; font-weight: bold; }
.header .school {
font-size: 12pt;
font-weight: bold;
text-transform: uppercase;
margin-top: 4mm;
}
.header .address { font-size: 11pt; margin-top: 2mm; }
.title {
text-align: center;
font-weight: bold;
font-size: 13pt;
margin: 8mm 0 5mm;
text-transform: uppercase;
}
.subtitle { text-align: center; font-size: 12pt; margin-bottom: 5mm; }
.date { text-align: right; margin-bottom: 5mm; }
.body-text { text-indent: 10mm; margin-bottom: 5mm; text-align: justify; }
table {
width: 100%;
border-collapse: collapse;
margin: 5mm 0;
}
table th, table td {
border: 1px solid #000;
padding: 3mm 2mm;
text-align: center;
font-size: 11pt;
}
table th { font-weight: bold; background: #f0f0f0; }
table td:first-child { width: 10mm; }
table td:nth-child(2) { text-align: left; }
.signature {
margin-top: 15mm;
text-align: right;
}
.signature .dir { margin-bottom: 8mm; }
.signature .name { font-weight: bold; }
@media print {
body { padding: 10mm 15mm; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.no-print { display: none !important; }
@page { size: A4; margin: 10mm; }
}
.no-print {
position: fixed;
top: 10px;
right: 10px;
z-index: 1000;
}
.btn-print {
padding: 8px 20px;
font-size: 12pt;
background: #0066cc;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>
</head>
<body>
<div class="no-print">
<button class="btn-print" onclick="window.print()">Печать</button>
</div>
<div class="header">
<div class="dept">ДЕПАРТАМЕНТ ОБРАЗОВАНИЯ АДМИНИСТРАЦИИ ГОРОДА ЕКАТЕРИНБУРГА</div>
<div class="dept">УПРАВЛЕНИЕ ОБРАЗОВАНИЯ АКАДЕМИЧЕСКОГО РАЙОНА</div>
<div class="school">
МУНИЦИПАЛЬНОЕ АВТОНОМНОЕ ОБЩЕОБРАЗОВАТЕЛЬНОЕ УЧРЕЖДЕНИЕ —<br>
СРЕДНЯЯ ОБЩЕОБРАЗОВАТЕЛЬНАЯ ШКОЛА № 25 им. В.Г. ФЕОФАНОВА
</div>
<div class="address">ул. Цветоносная, 2, тел. 334-32-26</div>
</div>
<div class="title">
СПРАВКА № <%= certNumber %><br>
о результатах государственной итоговой аттестации
</div>
<div class="subtitle">
<% if (type === 'gia') { %>
в форме основного государственного экзамена
<% } else { %>
в форме государственного выпускного экзамена
<% } %>
</div>
<div class="date">Дата выдачи <%= dateStr %></div>
<div class="body-text">
Настоящая справка выдана <strong><%= student.fioDative %></strong>
о том, что он(а) <%= verbEnding %>
в Муниципальном автономном общеобразовательном учреждении — средней общеобразовательной
школе № 25 имени В.Г. Феофанова по основной образовательной программе основного общего
образования и по результатам государственной итоговой аттестации
<%= verbEnding2 %> в 2025/2026 учебном году по учебным предметам следующие отметки
<% if (type === 'gia') { %>(количество первичных баллов)<% } %>:
</div>
<table>
<thead>
<tr>
<th>№</th>
<th>Наименование учебных предметов</th>
<% if (type === 'gia') { %>
<th>Количество первичных баллов</th>
<% } %>
<th>Отметка по пятибалльной шкале</th>
</tr>
</thead>
<tbody>
<% allSubjects.forEach((s, i) => { %>
<tr>
<td><%= i + 1 %></td>
<td><%= s.subject_name %></td>
<% if (type === 'gia') { %>
<td><%= s.primary_score !== null ? s.primary_score : '—' %></td>
<% } %>
<td><%= s.grade !== null ? s.grade + ' (' + gradeText(s.grade) + ')' : '—' %></td>
</tr>
<% }) %>
</tbody>
</table>
<div class="signature">
<div class="dir">Директор</div>
<div class="name">О.А. Добычина</div>
</div>
<%
function gradeText(g) {
const map = { 5: 'отлично', 4: 'хорошо', 3: 'удовлетворительно', 2: 'неудовлетворительно' };
return map[g] || '';
}
%>
<script>
window.onload = () => setTimeout(() => window.print(), 500);
</script>
</body>
</html>

85
views/import.ejs Normal file
View File

@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Импорт XLS — Справки</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>Импорт данных из XLS</h1>
<div class="card">
<form id="import-form" enctype="multipart/form-data">
<div class="form-row">
<input type="file" name="xlsfile" accept=".xls,.xlsx" class="input" required>
</div>
<button type="submit" class="btn btn-green">Загрузить и распарсить</button>
</form>
<div id="import-result" style="margin-top:15px"></div>
</div>
<div class="card">
<h2>Инструкция</h2>
<ol>
<li>Выберите XLS-файл с протоколом ГИА-9</li>
<li>Нажмите «Загрузить и распарсить»</li>
<li>Данные сохранятся в базу</li>
<li>Затем перейдите в карточку ученика и введите баллы по русскому и математике</li>
<li>Сформируйте справку (ГИА или ГВЭ)</li>
</ol>
</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';
});
document.getElementById('import-form').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const btn = e.target.querySelector('button');
const result = document.getElementById('import-result');
btn.disabled = true;
btn.textContent = 'Идёт импорт...';
result.innerHTML = '';
try {
const res = await fetch('/api/import', { method: 'POST', body: formData });
const data = await res.json();
if (res.ok) {
result.innerHTML = `<div class="msg msg-ok">Импортировано: ${data.students} учеников, ${data.subjects} предметов, ${data.results} результатов.</div>`;
} else {
result.innerHTML = `<div class="msg msg-err">Ошибка: ${data.error}</div>`;
}
} catch (err) {
result.innerHTML = `<div class="msg msg-err">Ошибка: ${err.message}</div>`;
}
btn.disabled = false;
btn.textContent = 'Загрузить и распарсить';
});
</script>
</body>
</html>

173
views/index.ejs Normal file
View File

@@ -0,0 +1,173 @@
<!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>
<span class="user-bar"><%= user ? user.name : '' %></span>
<button id="logout-btn" class="btn btn-logout">Выйти</button>
</nav>
<div class="card batch-panel">
<h2>Пакетная печать</h2>
<div class="form-row">
<label>
<input type="radio" name="batch-scope" value="class" checked onchange="toggleBatchMode()"> Класс
</label>
<select id="class-select" class="input">
<option value="">— загрузка... —</option>
</select>
<label style="margin-left:15px">
<input type="radio" name="batch-scope" value="parallel" onchange="toggleBatchMode()"> Параллель
</label>
<select id="parallel-select" class="input" disabled>
<option value="">— загрузка... —</option>
</select>
<label style="margin-left:15px">
<input type="radio" name="batch-scope" value="all" onchange="toggleBatchMode()"> Все ученики
</label>
</div>
<div class="form-row" style="margin-top:8px">
<label>
<input type="radio" name="batch-type" value="gia" checked> ГИА (ОГЭ)
</label>
<label style="margin-left:15px">
<input type="radio" name="batch-type" value="gve"> ГВЭ
</label>
<button id="batch-print-btn" class="btn btn-blue" style="margin-left:15px">Печать справок</button>
</div>
</div>
<div class="search-bar">
<input type="text" id="search" class="input" placeholder="Поиск по ФИО или классу...">
</div>
<table id="students-table">
<thead>
<tr>
<th>№</th>
<th>Фамилия</th>
<th>Имя</th>
<th>Отчество</th>
<th>Класс</th>
<th>Предметов</th>
<th>Действия</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div class="toolbar">
<button id="clear-data" class="btn btn-danger">Очистить все данные</button>
</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);
};
async function checkAuth() {
const r = await fetch('/api/user');
if (!r.ok) window.location.href = '/login';
}
checkAuth();
</script>
<script>
let allStudents = [];
async function loadStudents(search = '') {
const url = search ? `/api/students?search=${encodeURIComponent(search)}` : '/api/students';
const res = await fetch(url);
allStudents = await res.json();
renderTable(allStudents);
}
async function loadClasses() {
const res = await fetch('/api/classes');
const classes = await res.json();
const clsSel = document.getElementById('class-select');
clsSel.innerHTML = classes.map(c => `<option value="${esc(c)}">${esc(c)}</option>`).join('');
const parallels = [...new Set(classes.map(c => c.charAt(0)).filter(Boolean))].sort();
const parSel = document.getElementById('parallel-select');
parSel.innerHTML = parallels.map(p => `<option value="${esc(p)}">${esc(p)}-е классы</option>`).join('');
}
function toggleBatchMode() {
const scope = document.querySelector('input[name="batch-scope"]:checked').value;
document.getElementById('class-select').disabled = scope !== 'class';
document.getElementById('parallel-select').disabled = scope !== 'parallel';
}
function renderTable(students) {
const tbody = document.querySelector('#students-table tbody');
tbody.innerHTML = students.map((s, i) => `
<tr>
<td>${i + 1}</td>
<td>${esc(s.last_name)}</td>
<td>${esc(s.first_name)}</td>
<td>${esc(s.patronymic)}</td>
<td>${esc(s.class)}</td>
<td>${s.exam_count}</td>
<td><a href="/student/${s.id}" class="btn btn-sm">Открыть</a></td>
</tr>
`).join('');
}
function esc(s) { return (s || '').replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
document.getElementById('search').addEventListener('input', (e) => {
loadStudents(e.target.value);
});
document.getElementById('clear-data').addEventListener('click', async () => {
if (!confirm('Удалить всех учеников и результаты?')) return;
await fetch('/api/data', { method: 'DELETE' });
loadStudents();
});
document.getElementById('batch-print-btn').addEventListener('click', () => {
const scope = document.querySelector('input[name="batch-scope"]:checked').value;
const type = document.querySelector('input[name="batch-type"]:checked').value;
let url = `/batch-print?type=${type}`;
if (scope === 'class') {
const cls = document.getElementById('class-select').value;
if (!cls) return alert('Выберите класс');
url += `&class=${encodeURIComponent(cls)}`;
} else if (scope === 'parallel') {
const par = document.getElementById('parallel-select').value;
if (!par) return alert('Выберите параллель');
url += `&parallel=${encodeURIComponent(par)}`;
}
window.open(url, '_blank');
});
document.getElementById('logout-btn').addEventListener('click', async () => {
await fetch('/api/logout', { method: 'POST' });
window.location.href = '/login';
});
loadStudents();
loadClasses();
</script>
</body>
</html>

145
views/login.ejs Normal file
View File

@@ -0,0 +1,145 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Вход — Справки ГИА/ГВЭ</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f0f2f5;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.login-box {
background: #fff;
padding: 40px 35px;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0,0,0,0.1);
width: 360px;
}
.login-box h1 {
text-align: center;
margin-bottom: 6px;
font-size: 20px;
color: #333;
}
.login-box .subtitle {
text-align: center;
color: #888;
font-size: 13px;
margin-bottom: 24px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 4px;
font-weight: 500;
font-size: 14px;
color: #444;
}
.form-group input {
width: 100%;
padding: 10px 12px;
border: 1px solid #d0d0d0;
border-radius: 5px;
font-size: 15px;
outline: none;
transition: border-color 0.2s;
}
.form-group input:focus {
border-color: #0066cc;
}
.btn-login {
width: 100%;
padding: 10px;
background: #0066cc;
color: #fff;
border: none;
border-radius: 5px;
font-size: 15px;
cursor: pointer;
margin-top: 4px;
}
.btn-login:hover { background: #0052a3; }
.btn-login:disabled { opacity: 0.6; cursor: not-allowed; }
.error-msg {
color: #c0392b;
font-size: 13px;
text-align: center;
margin-top: 10px;
display: none;
}
.error-msg.show { display: block; }
.school-name {
text-align: center;
font-size: 12px;
color: #aaa;
margin-top: 20px;
}
</style>
</head>
<body>
<div class="login-box">
<h1>Справки ГИА / ГВЭ</h1>
<div class="subtitle">МАОУ СОШ № 25 им. В.Г. Феофанова</div>
<form id="login-form" autocomplete="off">
<div class="form-group">
<label for="login">Логин</label>
<input type="text" id="login" required autocomplete="off">
</div>
<div class="form-group">
<label for="password">Пароль</label>
<input type="password" id="password" required autocomplete="off">
</div>
<button type="submit" class="btn-login">Войти</button>
<div id="error-msg" class="error-msg"></div>
</form>
<div class="school-name">Школа № 25 &copy; 2026</div>
</div>
<script>
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = e.target.querySelector('button');
const errMsg = document.getElementById('error-msg');
btn.disabled = true;
btn.textContent = 'Вход...';
errMsg.classList.remove('show');
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
login: document.getElementById('login').value,
password: document.getElementById('password').value
})
});
const data = await res.json();
if (res.ok && data.success) {
window.__CSRF_TOKEN = data.csrfToken;
window.location.href = '/';
} else {
errMsg.textContent = data.error || 'Ошибка входа';
errMsg.classList.add('show');
}
} catch (err) {
errMsg.textContent = 'Ошибка соединения с сервером';
errMsg.classList.add('show');
}
btn.disabled = false;
btn.textContent = 'Войти';
});
fetch('/api/user').then(r => {
if (r.ok) window.location.href = '/';
});
</script>
</body>
</html>

160
views/student.ejs Normal file
View File

@@ -0,0 +1,160 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= student.last_name %> <%= student.first_name %> — Справки</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><%= student.last_name %> <%= student.first_name %> <%= student.patronymic %></h1>
<p>Класс: <strong><%= student.class %></strong></p>
<div class="card">
<h2>Обязательные предметы</h2>
<form id="mandatory-form" class="mandatory-form">
<div class="form-row">
<label>Русский язык</label>
<input type="number" id="rus-score" class="input input-sm" placeholder="Первичный балл" value="<%= rusResult ? rusResult.primary_score || '' : '' %>">
<input type="number" id="rus-grade" class="input input-sm" placeholder="Оценка (2-5)" min="2" max="5" value="<%= rusResult ? rusResult.grade || '' : '' %>">
</div>
<div class="form-row">
<label>Математика</label>
<input type="number" id="math-score" class="input input-sm" placeholder="Первичный балл" value="<%= mathResult ? mathResult.primary_score || '' : '' %>">
<input type="number" id="math-grade" class="input input-sm" placeholder="Оценка (2-5)" min="2" max="5" value="<%= mathResult ? mathResult.grade || '' : '' %>">
</div>
<button type="submit" class="btn btn-green">Сохранить</button>
<span id="mandatory-msg" class="msg"></span>
</form>
</div>
<% if (electiveResults.length > 0) { %>
<div class="card">
<h2>Предметы по выбору (из протокола)</h2>
<table>
<thead>
<tr>
<th>Предмет</th>
<th>Первичный балл</th>
<th>Оценка</th>
</tr>
</thead>
<tbody>
<% electiveResults.forEach(r => { %>
<tr>
<td><%= r.subject_name %></td>
<td><%= r.primary_score !== null ? r.primary_score : '—' %></td>
<td><strong><%= r.grade !== null ? r.grade : '—' %></strong></td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<% } %>
<div class="card">
<h2>Сформировать справку</h2>
<form id="cert-form">
<div class="form-row">
<label>
<input type="radio" name="cert-type" value="gia" checked onchange="toggleElectives()">
ГИА (ОГЭ) — 4 предмета, с первичными баллами
</label>
</div>
<div class="form-row">
<label>
<input type="radio" name="cert-type" value="gve" onchange="toggleElectives()">
ГВЭ — 2 предмета, только отметки
</label>
</div>
<div id="electives-block" style="margin-top:10px">
<p>Выберите 12 предмета для справки ГИА:</p>
<% electiveResults.forEach(r => { %>
<label class="checkbox-label">
<input type="checkbox" name="elective" value="<%= r.subject_code %>" class="elective-cb">
<%= r.subject_name %>
</label>
<% }) %>
<span id="elective-msg" class="msg"></span>
</div>
<button type="submit" class="btn btn-blue" style="margin-top:15px">Сформировать и распечатать</button>
</form>
</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';
});
document.getElementById('mandatory-form').addEventListener('submit', async (e) => {
e.preventDefault();
const score = v => { const n = parseInt(v,10); return isNaN(n)?null:n; };
const res = await fetch('/api/students/<%= student.id %>/mandatory', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rus_score: score(document.getElementById('rus-score').value),
rus_grade: score(document.getElementById('rus-grade').value),
math_score: score(document.getElementById('math-score').value),
math_grade: score(document.getElementById('math-grade').value)
})
});
const msg = document.getElementById('mandatory-msg');
if (res.ok) { msg.textContent = 'Сохранено'; msg.className = 'msg msg-ok'; }
else { msg.textContent = 'Ошибка'; msg.className = 'msg msg-err'; }
});
document.querySelectorAll('.elective-cb').forEach(cb => {
cb.addEventListener('change', () => {
const checked = document.querySelectorAll('.elective-cb:checked');
if (checked.length > 2) cb.checked = false;
});
});
function toggleElectives() {
const type = document.querySelector('input[name="cert-type"]:checked').value;
document.getElementById('electives-block').style.display = type === 'gia' ? 'block' : 'none';
}
document.getElementById('cert-form').addEventListener('submit', (e) => {
e.preventDefault();
const type = document.querySelector('input[name="cert-type"]:checked').value;
const electives = [...document.querySelectorAll('.elective-cb:checked')].map(cb => cb.value);
const msg = document.getElementById('elective-msg');
if (type === 'gia' && electives.length < 1) {
msg.textContent = 'Выберите хотя бы 1 предмет для ГИА';
msg.className = 'msg msg-err';
return;
}
msg.textContent = '';
const url = `/certificate/<%= student.id %>?type=${type}&subjects=${electives.join(',')}`;
window.open(url, '_blank');
});
</script>
</body>
</html>