v
This commit is contained in:
8
.env.example
Normal file
8
.env.example
Normal 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
140
.gitignore
vendored
Normal 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
22
package.json
Normal 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
88
public/style.css
Normal 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
151
server.js
Normal 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
121
src/auth.js
Normal 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
139
src/db.js
Normal 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
170
src/parser.js
Normal 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
145
src/routes/api.js
Normal 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
230
src/routes/pages.js
Normal 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
172
views/batch-certificate.ejs
Normal 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
159
views/certificate.ejs
Normal 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
85
views/import.ejs
Normal 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">← К списку</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
173
views/index.ejs
Normal 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 => ({'&':'&','<':'<','>':'>','"':'"'}[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 += `¶llel=${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
145
views/login.ejs
Normal 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 © 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
160
views/student.ejs
Normal 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">← К списку</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>Выберите 1–2 предмета для справки ГИА:</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>
|
||||
Reference in New Issue
Block a user