This commit is contained in:
32
modules/about/index.js
Normal file
32
modules/about/index.js
Normal file
@@ -0,0 +1,32 @@
|
||||
const { createCrudRoutes } = require('../database/crud');
|
||||
|
||||
let db;
|
||||
function init(database) { db = database; }
|
||||
|
||||
function setupRoutes(app, auth) {
|
||||
app.get('/api/admin/about-settings', auth.authenticateToken, auth.requireAdmin, (req, res) => {
|
||||
db.all(`SELECT * FROM about_settings ORDER BY id`, (err, rows) => {
|
||||
if (err) return res.status(500).json({ error: 'Ошибка БД' });
|
||||
const obj = {};
|
||||
rows.forEach(r => { obj[r.key] = r.value; });
|
||||
res.json(obj);
|
||||
});
|
||||
});
|
||||
|
||||
app.put('/api/admin/about-settings', auth.authenticateToken, auth.requireAdmin, (req, res) => {
|
||||
const entries = req.body;
|
||||
if (!entries || typeof entries !== 'object') return res.status(400).json({ error: 'Неверные данные' });
|
||||
const stmt = db.prepare(`UPDATE about_settings SET value = ? WHERE key = ?`);
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
stmt.run(value, key);
|
||||
}
|
||||
stmt.finalize();
|
||||
res.json({ message: 'Обновлено' });
|
||||
});
|
||||
|
||||
createCrudRoutes(app, auth, 'about_features', {
|
||||
fields: ['svg_icon', 'title', 'description', 'sort_order']
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { init, setupRoutes };
|
||||
55
modules/activities/index.js
Normal file
55
modules/activities/index.js
Normal file
@@ -0,0 +1,55 @@
|
||||
const { createCrudRoutes } = require('../database/crud');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const sharp = require('sharp');
|
||||
|
||||
let db;
|
||||
|
||||
function init(database) { db = database; }
|
||||
|
||||
function setupRoutes(app, auth) {
|
||||
const uploadDir = path.join(__dirname, '..', '..', 'data', 'activity_images');
|
||||
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: uploadDir,
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, 'act_' + Date.now() + ext);
|
||||
}
|
||||
});
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowed = ['.jpg', '.jpeg', '.png', '.webp'];
|
||||
cb(null, allowed.includes(path.extname(file.originalname).toLowerCase()));
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/admin/activities/upload', auth.authenticateToken, auth.requireAdmin, upload.single('image'), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'Файл не загружен' });
|
||||
let fname = req.file.filename;
|
||||
const ext = path.extname(fname).toLowerCase();
|
||||
if (ext !== '.webp') {
|
||||
try {
|
||||
const webpName = path.basename(fname, ext) + '.webp';
|
||||
await sharp(req.file.path).webp({ quality: 85 }).toFile(path.join(uploadDir, webpName));
|
||||
fs.unlinkSync(req.file.path);
|
||||
fname = webpName;
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
res.json({ path: fname, url: `/data/activity_images/${fname}` });
|
||||
});
|
||||
|
||||
createCrudRoutes(app, auth, 'public_activities', {
|
||||
fields: ['svg_icon', 'title', 'description', 'sort_order']
|
||||
});
|
||||
|
||||
createCrudRoutes(app, auth, 'activity_gallery', {
|
||||
fields: ['title', 'description', 'image_path', 'sort_order']
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { init, setupRoutes };
|
||||
124
modules/auth/index.js
Normal file
124
modules/auth/index.js
Normal file
@@ -0,0 +1,124 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
let db;
|
||||
|
||||
function init(database) {
|
||||
db = database;
|
||||
}
|
||||
|
||||
function authenticateToken(req, res, next) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Требуется авторизация' });
|
||||
}
|
||||
jwt.verify(token, process.env.HOTEL777KEY, (err, user) => {
|
||||
if (err) {
|
||||
return res.status(403).json({ error: 'Недействительный токен' });
|
||||
}
|
||||
req.user = user;
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
function requireAdmin(req, res, next) {
|
||||
if (req.user && req.user.role === 'admin') {
|
||||
return next();
|
||||
}
|
||||
return res.status(403).json({ error: 'Требуются права администратора' });
|
||||
}
|
||||
|
||||
function syncAdmin() {
|
||||
return new Promise((resolve) => {
|
||||
const login = process.env.ADMIN_USERNAME || 'admin';
|
||||
const password = process.env.ADMIN_PASSWORD || 'admin123';
|
||||
const hash = bcrypt.hashSync(password, 10);
|
||||
|
||||
db.get(`SELECT id FROM users WHERE login = ?`, [login], (err, row) => {
|
||||
if (row) {
|
||||
db.run(`UPDATE users SET password_hash = ?, role = 'admin' WHERE login = ?`, [hash, login], function() {
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
db.run(`INSERT INTO users (login, password_hash, full_name, role) VALUES (?, ?, ?, 'admin')`,
|
||||
[login, hash, 'Администратор'], function() {
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setupRoutes(app) {
|
||||
app.post('/api/auth/login', (req, res) => {
|
||||
const { login, password } = req.body;
|
||||
if (!login || !password) {
|
||||
return res.status(400).json({ error: 'Логин и пароль обязательны' });
|
||||
}
|
||||
|
||||
db.get(`SELECT * FROM users WHERE login = ?`, [login], (err, user) => {
|
||||
if (err || !user) {
|
||||
return res.status(401).json({ error: 'Неверный логин или пароль' });
|
||||
}
|
||||
|
||||
const valid = bcrypt.compareSync(password, user.password_hash);
|
||||
if (!valid) {
|
||||
return res.status(401).json({ error: 'Неверный логин или пароль' });
|
||||
}
|
||||
|
||||
const token = jwt.sign(
|
||||
{ id: user.id, login: user.login, role: user.role },
|
||||
process.env.HOTEL777KEY,
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
login: user.login,
|
||||
full_name: user.full_name,
|
||||
role: user.role
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/auth/me', authenticateToken, (req, res) => {
|
||||
db.get(`SELECT id, login, full_name, role, created_at FROM users WHERE id = ?`, [req.user.id], (err, user) => {
|
||||
if (err || !user) {
|
||||
return res.status(404).json({ error: 'Пользователь не найден' });
|
||||
}
|
||||
res.json(user);
|
||||
});
|
||||
});
|
||||
|
||||
app.put('/api/auth/me', authenticateToken, (req, res) => {
|
||||
const { full_name } = req.body;
|
||||
db.run(`UPDATE users SET full_name = ? WHERE id = ?`, [full_name || '', req.user.id], function(err) {
|
||||
if (err) return res.status(500).json({ error: 'Ошибка обновления' });
|
||||
res.json({ message: 'Профиль обновлён' });
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/auth/change-password', authenticateToken, (req, res) => {
|
||||
const { old_password, new_password } = req.body;
|
||||
if (!old_password || !new_password) {
|
||||
return res.status(400).json({ error: 'Старый и новый пароль обязательны' });
|
||||
}
|
||||
db.get(`SELECT password_hash FROM users WHERE id = ?`, [req.user.id], (err, user) => {
|
||||
if (err || !user) return res.status(404).json({ error: 'Пользователь не найден' });
|
||||
if (!bcrypt.compareSync(old_password, user.password_hash)) {
|
||||
return res.status(400).json({ error: 'Неверный старый пароль' });
|
||||
}
|
||||
const hash = bcrypt.hashSync(new_password, 10);
|
||||
db.run(`UPDATE users SET password_hash = ? WHERE id = ?`, [hash, req.user.id], (err2) => {
|
||||
if (err2) return res.status(500).json({ error: 'Ошибка обновления пароля' });
|
||||
res.json({ message: 'Пароль изменён' });
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { init, authenticateToken, requireAdmin, syncAdmin, setupRoutes };
|
||||
65
modules/database/crud.js
Normal file
65
modules/database/crud.js
Normal file
@@ -0,0 +1,65 @@
|
||||
function createCrudRoutes(app, auth, table, options = {}) {
|
||||
const { authenticateToken, requireAdmin } = auth;
|
||||
const prefix = options.prefix || `/api/admin/${table}`;
|
||||
const allowedFields = options.fields || [];
|
||||
const extraRoutes = options.extraRoutes || null;
|
||||
|
||||
app.get(prefix, authenticateToken, requireAdmin, (req, res) => {
|
||||
const orderBy = options.orderBy || 'sort_order';
|
||||
db.all(`SELECT * FROM ${table} ORDER BY ${orderBy}`, (err, rows) => {
|
||||
if (err) { console.error(err); return res.status(500).json({ error: 'Ошибка БД' }); }
|
||||
res.json(rows);
|
||||
});
|
||||
});
|
||||
|
||||
app.post(prefix, authenticateToken, requireAdmin, (req, res) => {
|
||||
const values = allowedFields.map(f => req.body[f] !== undefined ? req.body[f] : null);
|
||||
const placeholders = values.map(() => '?').join(', ');
|
||||
const cols = allowedFields.join(', ');
|
||||
const stmt = db.prepare(`INSERT INTO ${table} (${cols}) VALUES (${placeholders})`);
|
||||
stmt.run(...values, function(err) {
|
||||
if (err) { console.error(err); return res.status(500).json({ error: 'Ошибка создания' }); }
|
||||
db.get(`SELECT * FROM ${table} WHERE id = ?`, [this.lastID], (err2, row) => {
|
||||
if (err2) return res.status(500).json({ error: 'Ошибка получения' });
|
||||
res.status(201).json(row);
|
||||
});
|
||||
});
|
||||
stmt.finalize();
|
||||
});
|
||||
|
||||
app.put(`${prefix}/:id`, authenticateToken, requireAdmin, (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) return res.status(400).json({ error: 'Неверный ID' });
|
||||
const setClauses = allowedFields.map(f => `${f} = ?`).join(', ');
|
||||
const values = allowedFields.map(f => req.body[f] !== undefined ? req.body[f] : null);
|
||||
values.push(id);
|
||||
const stmt = db.prepare(`UPDATE ${table} SET ${setClauses} WHERE id = ?`);
|
||||
stmt.run(...values, function(err) {
|
||||
if (err) { console.error(err); return res.status(500).json({ error: 'Ошибка обновления' }); }
|
||||
if (this.changes === 0) return res.status(404).json({ error: 'Не найдено' });
|
||||
db.get(`SELECT * FROM ${table} WHERE id = ?`, [id], (err2, row) => {
|
||||
if (err2) return res.status(500).json({ error: 'Ошибка получения' });
|
||||
res.json(row);
|
||||
});
|
||||
});
|
||||
stmt.finalize();
|
||||
});
|
||||
|
||||
app.delete(`${prefix}/:id`, authenticateToken, requireAdmin, (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) return res.status(400).json({ error: 'Неверный ID' });
|
||||
const stmt = db.prepare(`DELETE FROM ${table} WHERE id = ?`);
|
||||
stmt.run(id, function(err) {
|
||||
if (err) { console.error(err); return res.status(500).json({ error: 'Ошибка удаления' }); }
|
||||
if (this.changes === 0) return res.status(404).json({ error: 'Не найдено' });
|
||||
res.json({ message: 'Удалено' });
|
||||
});
|
||||
stmt.finalize();
|
||||
});
|
||||
|
||||
if (extraRoutes) extraRoutes(app, authenticateToken, requireAdmin);
|
||||
}
|
||||
|
||||
let db;
|
||||
function init(database) { db = database; }
|
||||
module.exports = { createCrudRoutes, init };
|
||||
301
modules/database/index.js
Normal file
301
modules/database/index.js
Normal file
@@ -0,0 +1,301 @@
|
||||
let db;
|
||||
|
||||
function init(database) {
|
||||
db = database;
|
||||
return initDatabase();
|
||||
}
|
||||
|
||||
function initDatabase() {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.serialize(() => {
|
||||
db.run(`CREATE TABLE IF NOT EXISTS leads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
phone TEXT NOT NULL,
|
||||
message TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`);
|
||||
db.run(`CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
login TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
full_name TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'admin',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`);
|
||||
db.run(`CREATE TABLE IF NOT EXISTS hero_settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT UNIQUE NOT NULL,
|
||||
value TEXT
|
||||
)`);
|
||||
db.run(`CREATE TABLE IF NOT EXISTS hero_stats (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
label TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
is_dynamic INTEGER DEFAULT 0,
|
||||
sort_order INTEGER DEFAULT 0
|
||||
)`);
|
||||
db.run(`CREATE TABLE IF NOT EXISTS about_settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT UNIQUE NOT NULL,
|
||||
value TEXT
|
||||
)`);
|
||||
db.run(`CREATE TABLE IF NOT EXISTS about_features (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
svg_icon TEXT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
sort_order INTEGER DEFAULT 0
|
||||
)`);
|
||||
db.run(`CREATE TABLE IF NOT EXISTS services (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
svg_icon TEXT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
items TEXT DEFAULT '[]',
|
||||
is_large INTEGER DEFAULT 0,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
is_active INTEGER DEFAULT 1
|
||||
)`);
|
||||
db.run(`CREATE TABLE IF NOT EXISTS work_schemes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
svg_icon TEXT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
items TEXT DEFAULT '[]',
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
is_active INTEGER DEFAULT 1
|
||||
)`);
|
||||
db.run(`CREATE TABLE IF NOT EXISTS process_steps (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
step_number INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
sort_order INTEGER DEFAULT 0
|
||||
)`);
|
||||
db.run(`CREATE TABLE IF NOT EXISTS equipment (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
brand TEXT,
|
||||
image_path TEXT,
|
||||
qty TEXT,
|
||||
specs TEXT DEFAULT '[]',
|
||||
age_badge TEXT DEFAULT '',
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
is_active INTEGER DEFAULT 1
|
||||
)`);
|
||||
db.run(`CREATE TABLE IF NOT EXISTS equipment_gallery (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
equipment_id INTEGER NOT NULL,
|
||||
image_path TEXT NOT NULL,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (equipment_id) REFERENCES equipment(id) ON DELETE CASCADE
|
||||
)`);
|
||||
db.run(`CREATE TABLE IF NOT EXISTS geo_regions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
sort_order INTEGER DEFAULT 0
|
||||
)`);
|
||||
db.run(`CREATE TABLE IF NOT EXISTS geo_settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT UNIQUE NOT NULL,
|
||||
value TEXT
|
||||
)`);
|
||||
db.run(`CREATE TABLE IF NOT EXISTS licenses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
svg_icon TEXT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
sort_order INTEGER DEFAULT 0
|
||||
)`);
|
||||
db.run(`CREATE TABLE IF NOT EXISTS license_gallery (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
image_path TEXT,
|
||||
sort_order INTEGER DEFAULT 0
|
||||
)`);
|
||||
db.run(`CREATE TABLE IF NOT EXISTS public_activities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
svg_icon TEXT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
sort_order INTEGER DEFAULT 0
|
||||
)`);
|
||||
db.run(`CREATE TABLE IF NOT EXISTS activity_gallery (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
image_path TEXT,
|
||||
sort_order INTEGER DEFAULT 0
|
||||
)`);
|
||||
db.run(`CREATE TABLE IF NOT EXISTS partners (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
logo_path TEXT,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
is_active INTEGER DEFAULT 1
|
||||
)`);
|
||||
db.run(`CREATE TABLE IF NOT EXISTS settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT UNIQUE NOT NULL,
|
||||
value TEXT
|
||||
)`);
|
||||
|
||||
db.get(`SELECT COUNT(*) as cnt FROM settings`, (err, row) => {
|
||||
if (row && row.cnt > 0) { resolve(); return; }
|
||||
seedAll().then(resolve).catch(reject);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function seedAll() {
|
||||
const run = (sql, params) => new Promise(r => db.run(sql, params || [], () => r()));
|
||||
const runEach = (sql, rows) => Promise.all(rows.map(r => {
|
||||
const stmt = db.prepare(sql);
|
||||
return new Promise(res => {
|
||||
stmt.run(...r, function() { stmt.finalize(); res(); });
|
||||
});
|
||||
}));
|
||||
|
||||
const heroDefaults = [
|
||||
['hero_bg', './img/hero-bg.webp'], ['badge_text', 'Лицензии СРО и НОСТРОЙ'],
|
||||
['h1_text', 'демонтаж нефтегазопроводов по России, утилизация отходов'],
|
||||
['subtitle', 'Собственная спецтехника. Лицензии СРО и НОСТРОЙ. Более {years} лет опыта. Работаем на опасных производственных объектах без привлечения субподрядчиков.'],
|
||||
['phone_800', '8 (800) 555-29-31'], ['phone_ekb', '8 (343) 237-25-66'],
|
||||
['email', 'sts_zakaz@mail.ru'], ['address', 'г. Екатеринбург, ул. Московская, 11, оф. 702'],
|
||||
['button1_text', 'Оставить заявку'], ['button1_link', '#contacts'],
|
||||
['button2_text', 'Наши услуги'], ['button2_link', '#services']
|
||||
];
|
||||
await runEach('INSERT OR IGNORE INTO hero_settings (key, value) VALUES (?, ?)', heroDefaults);
|
||||
|
||||
await runEach('INSERT INTO hero_stats (label, value, is_dynamic, sort_order) VALUES (?, ?, ?, ?)', [
|
||||
['лет на рынке', '{years}', 1, 0], ['объектов выполнено', '200', 0, 1],
|
||||
['единиц техники', '80', 0, 2], ['федеральных округов', '6', 0, 3]
|
||||
]);
|
||||
|
||||
await runEach('INSERT OR IGNORE INTO about_settings (key, value) VALUES (?, ?)', [
|
||||
['section_subtitle', 'Компания «СТС-Авто» — надёжный партнёр в сфере демонтажа, оказывает услуги по демонтажу магистральных трубопроводов, объектов инфраструктуры, зданий, сооружений и разделки жд транспорта, с последующей переработкой и утилизацией для предприятий. Мы работаем на всей территории Российской Федерации.'],
|
||||
['main_heading', 'Собственная техника и полный контроль'], ['about_image', './img/hero-bg.webp'],
|
||||
['paragraph_1', 'Наш автопарк состоит из более чем 80 единиц современной спецтехники, возраст которой не превышает 5 лет. Мы используем экскаваторы, краны, погрузчики, самосвалы и тралы ведущих мировых производителей: KAMAZ, Komatsu, Zoomlion, SDLG, Соболь, УАЗ.'],
|
||||
['paragraph_2', 'Все работы выполняются исключительно силами компании — без привлечения субподрядчиков. Это гарантирует полное соответствие качества, сроков и ответственности на каждом этапе проекта.'],
|
||||
['paragraph_3', 'Мы работаем в самых сложных условиях: труднодоступные районы, заболоченные территории, Крайний Север. Гибкие варианты оплаты: работаем по договору, включая вариант расчёта трубой за услуги (бартер с металлоломом после демонтажа).']
|
||||
]);
|
||||
|
||||
await runEach('INSERT INTO about_features (svg_icon, title, description, sort_order) VALUES (?, ?, ?, ?)', [
|
||||
['<path d="M18.92 6.01C18.72 5.42 18.16 5 17.5 5h-11c-.66 0-1.21.42-1.42 1.01L3 12v8c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h12v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-8l-2.08-5.99zM6.5 16c-.83 0-1.5-.67-1.5-1.5S5.67 13 6.5 13s1.5.67 1.5 1.5S7.33 16 6.5 16zm11 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zM5 11l1.5-4.5h11L19 11H5z"/>', 'Парк техники не старше 5 лет', 'Более 80 единиц современной спецтехники', 0],
|
||||
['<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>', 'Без субподрядчиков', 'Все работы силами компании', 1],
|
||||
['<path d="M19.35 10.04A7.49 7.49 0 0012 4C9.11 4 6.6 5.64 5.35 8.04A5.994 5.994 0 000 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z"/>', 'Сложные условия', 'Болота, Крайний Север, труднодоступные районы', 2],
|
||||
['<path d="M11.8 10.9c-2.27-.59-3-1.2-3-2.15 0-1.09 1.01-1.85 2.7-1.85 1.78 0 2.44.85 2.5 2.1h2.21c-.07-1.72-1.12-3.3-3.21-3.81V3h-3v2.16c-1.94.42-3.5 1.68-3.5 3.61 0 2.31 1.91 3.46 4.7 4.13 2.5.6 3 1.48 3 2.41 0 .69-.49 1.79-2.7 1.79-2.06 0-2.87-.92-2.98-2.1h-2.2c.12 2.19 1.76 3.42 3.68 3.83V21h3v-2.15c1.95-.37 3.5-1.5 3.5-3.55 0-2.84-2.43-3.81-4.7-4.4z"/>', 'Гибкая оплата', 'Включая расчёт трубой (бартер)', 3]
|
||||
]);
|
||||
|
||||
const svcIcon = {
|
||||
demolish: '<path d="M19.35 10.04A7.49 7.49 0 0012 4C9.11 4 6.6 5.64 5.35 8.04A5.994 5.994 0 000 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z"/>',
|
||||
recult: '<path d="M14 6l-3.75 5 2.85 3.8-1.6 1.2C9.81 13.75 7 10 7 10l-6 8h22L14 6z"/>',
|
||||
clear: '<path d="M17 12h-5v5h5v-5zM16 1v2H8V1H6v2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2h-1V1h-2zm3 18H5V8h14v11z"/>',
|
||||
lezh: '<path d="M20 2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-9 14H4v-3h7v3zm0-5H4V8h7v3zm9 5h-7v-3h7v3zm0-5h-7V8h7v3z"/>',
|
||||
scheme1: '<path d="M20 8h-3V4H3c-1.1 0-2 .9-2 2v11h2c0 1.66 1.34 3 3 3s3-1.34 3-3h6c0 1.66 1.34 3 3 3s3-1.34 3-3h2v-5l-3-4zM6 18.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm13.5-9l1.96 2.5H17V9.5h2.5zm-1.5 9c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z"/>',
|
||||
scheme2: '<path d="M11.8 10.9c-2.27-.59-3-1.2-3-2.15 0-1.09 1.01-1.85 2.7-1.85 1.78 0 2.44.85 2.5 2.1h2.21c-.07-1.72-1.12-3.3-3.21-3.81V3h-3v2.16c-1.94.42-3.5 1.68-3.5 3.61 0 2.31 1.91 3.46 4.7 4.13 2.5.6 3 1.48 3 2.41 0 .69-.49 1.79-2.7 1.79-2.06 0-2.87-.92-2.98-2.1h-2.2c.12 2.19 1.76 3.42 3.68 3.83V21h3v-2.15c1.95-.37 3.5-1.5 3.5-3.55 0-2.84-2.43-3.81-4.7-4.4z"/>'
|
||||
};
|
||||
|
||||
await runEach('INSERT INTO services (svg_icon, title, description, items, is_large, sort_order) VALUES (?, ?, ?, ?, ?, ?)', [
|
||||
[svcIcon.demolish, 'Демонтаж нефтегазопроводов, утилизация отходов', 'Выполняем полный цикл работ по демонтажу магистральных и промысловых трубопроводов любого типа и диаметра, а также утилизацию отходов.', '["Нефтепроводы, газопроводы, водоводы — все типы магистральных и промысловых сетей","Полный цикл: от вскрытия и вырезки дефектных участков до вывоза и сдачи металлолома","Работа с трубопроводами любого диаметра в любых климатических условиях","Разделка ж/д транспорта, переработка и утилизация для предприятий"]', 1, 0],
|
||||
[svcIcon.recult, 'Техническая рекультивация земель', 'Восстановление нарушенных земель после проведения строительных и ремонтных работ.', '["Засыпка траншей, планировка, восстановление плодородного слоя","Работа с нефтезагрязнёнными, засоленными землями","Сдача по актам под контролем экологов"]', 0, 1],
|
||||
[svcIcon.clear, 'Расчистка территории от мелколесья и ДКР', 'Подготовка полосы отвода: расчистка от древесно-кустарниковой растительности.', '["Валка, корчёвка, дробление пней","Планировка полосы отвода","Мульчеры и кусторезы на гусеничном ходу"]', 0, 2],
|
||||
[svcIcon.lezh, 'Устройство лежневого настила', 'Обеспечение проходимости для тяжёлой техники по болотам и слабым грунтам.', '["Изготовление секций из бревен и бруса","Укладка перекрытий на слабых грунтах","Обеспечение проходимости для тяжёлой гусеничной техники"]', 0, 3]
|
||||
]);
|
||||
|
||||
await runEach('INSERT INTO work_schemes (svg_icon, title, description, items, sort_order) VALUES (?, ?, ?, ?, ?)', [
|
||||
[svcIcon.scheme1, 'Схема 1 — с доставкой заказчику', 'Подходит для компаний, планирующих повторное использование труб или их последующую реализацию. Расходный договор для заказчика.', '["Полный цикл работ по демонтажу трубопровода","Резка и маркировка труб в соответствии с требованиями","Транспортировка демонтированных труб на указанные территории хранения","Оформление всей сопроводительной документации (акты приёма-передачи)"]', 0],
|
||||
[svcIcon.scheme2, 'Схема 2 — вывоз на базы подрядчика', 'Экономически выгодное решение для заказчиков, заинтересованных в минимизации затрат. Доходный договор для заказчика.', '["Полный цикл работ по демонтажу трубопровода","Перевозка и размещение на сертифицированных площадках временного хранения ООО «СТС-АВТО»","Покупка трубы в земле — заказчик получает доход от металлолома","Оформление полного пакета закрывающей документации"]', 1]
|
||||
]);
|
||||
|
||||
await runEach('INSERT INTO process_steps (step_number, title, description, sort_order) VALUES (?, ?, ?, ?)', [
|
||||
[1, 'Заявка', 'Вы оставляете заявку на сайте, по электронной почте или звоните нам. Наши специалисты оперативно свяжутся с вами для обсуждения деталей проекта, объёма работ и сроков.', 0],
|
||||
[2, 'Выезд специалиста и оценка', 'Наш инженер выезжает на объект для проведения осмотра, оценки объёма работ, определения необходимых ресурсов и техники. По результатам составляется техническое задание и подписывается договор.', 1],
|
||||
[3, 'Выполнение работ', 'Мобилизуем технику и персонал на объект. Выполняем демонтаж, утилизацию отходов и рекультивацию в соответствии с проектной документацией. Ежедневные отчёты о ходе работ.', 2],
|
||||
[4, 'Сдача объекта', 'По завершении всех работ подписывается акт выполненных работ, передаётся полный комплект закрывающей документации. При необходимости — сопровождение сдачи объекта экспертизе.', 3]
|
||||
]);
|
||||
|
||||
await runEach('INSERT INTO equipment (name, brand, image_path, qty, specs, age_badge, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)', [
|
||||
['Автокран КАМАЗ КС-55717К-3', 'КАМАЗ', 'ks-45721.webp', '10 единиц', '[{"label":"Грузоподъёмность","value":"25 тонн"},{"label":"Вылет стрелы","value":"до 28 м"},{"label":"Шасси","value":"Полноприводное 6×6"}]', 'Не старше 5 лет', 0],
|
||||
['Автокран Zoomlion ZTC300', 'Zoomlion', 'zoomlion-ztc300.webp', '2 единицы', '[{"label":"Грузоподъёмность","value":"30 тонн"},{"label":"Вылет стрелы","value":"до 32 м"},{"label":"Стрела","value":"Телескопическая"}]', 'Не старше 5 лет', 1],
|
||||
['Экскаватор гусеничный Komatsu PC210-10M0', 'Komatsu', 'komatsu-pc210.webp', '20 единиц', '[{"label":"Масса","value":"21 тонна"},{"label":"Объём ковша","value":"1.0–1.2 м³"},{"label":"Глубина копания","value":"до 6.5 м"}]', 'Не старше 5 лет', 2],
|
||||
['Экскаватор гусеничный Zoomlion ZE215E', 'Zoomlion', 'zoomlion-ze215e.webp', '2 единицы', '[{"label":"Масса","value":"21.5 тонны"},{"label":"Объём ковша","value":"1.1 м³"},{"label":"Аналог","value":"Komatsu PC210"}]', 'Не старше 5 лет', 3],
|
||||
['Погрузчик колёсный SDLG LG933L', 'SDLG', 'sdlg-lg933l.webp', '2 единицы', '[{"label":"Грузоподъёмность","value":"3 тонны"},{"label":"Объём ковша","value":"1.7 м³"},{"label":"Тип","value":"Колёсный"}]', 'Не старше 5 лет', 4],
|
||||
['Соболь 4×4 грузо-пассажирский', 'Соболь', 'sobol-4x4.webp', '13 единиц', '[{"label":"Грузоподъёмность","value":"до 1 т"},{"label":"Привод","value":"Полный 4×4"},{"label":"Назначение","value":"Доставка персонала и запчастей"}]', 'Не старше 5 лет', 5],
|
||||
['Ломовоз с КМУ (кран-манипулятор)', 'Ломовоз', 'lomovoz-kmu.webp', '3 единицы', '[{"label":"Грузоподъёмность КМУ","value":"до 8 т"},{"label":"Объём платформы","value":"до 20 м³"},{"label":"Назначение","value":"Вывоз металлолома"}]', 'Не старше 5 лет', 6],
|
||||
['Седельный тягач Камаз 53504-50', 'КамАЗ', 'kamaz-53504.webp', '6 единиц', '[{"label":"Тяговый класс","value":"до 50 т"},{"label":"Привод","value":"Полный привод"},{"label":"Назначение","value":"Перевозка негабарита и тралов"}]', 'Не старше 5 лет', 7],
|
||||
['Экскаватор-амфибия на понтонной платформе', 'Амфибия', 'amfibia.webp', '4 единицы', '[{"label":"Тип","value":"Гусеничный, плавающий"},{"label":"Работает","value":"На болотах и водных преградах"},{"label":"Особенность","value":"Там, где не видно дна"}]', 'Не старше 5 лет', 8],
|
||||
['УАЗ Пикап (полный привод)', 'УАЗ', 'uaz-pickup.webp', 'Несколько единиц', '[{"label":"Привод","value":"Полный привод"},{"label":"Назначение","value":"Доставка запчастей на месторождения"}]', 'Не старше 5 лет', 9],
|
||||
['Тралы тяжеловозы для доставки спецтехники', 'Тралы', 'tral-1.webp', '3 единицы', '[{"label":"Тяговый класс","value":"до 50 т"},{"label":"Назначение","value":"Доставка спецтехники на объекты"}]', 'Не старше 5 лет', 10]
|
||||
]);
|
||||
|
||||
await runEach('INSERT INTO geo_regions (name, sort_order) VALUES (?, ?)', [
|
||||
['УрФО', 0], ['СФО', 1], ['ПФО', 2], ['СЗФО', 3], ['ДФО', 4], ['ЮФО', 5]
|
||||
]);
|
||||
|
||||
await runEach('INSERT OR IGNORE INTO geo_settings (key, value) VALUES (?, ?)', [
|
||||
['section_subtitle', 'Работаем во всех федеральных округах Российской Федерации. Особые условия для труднодоступных районов Крайнего Севера и заболоченных территорий.'],
|
||||
['difficult_title', '🔧 Труднодоступные районы'],
|
||||
['difficult_desc', 'Мы располагаем специализированной техникой для работы в болотах ХМАО-Югры, ЯНАО, на севере Красноярского края, в Иркутской области. Гусеничная техника и лежневые настилы обеспечивают проходимость даже в самых сложных условиях.'],
|
||||
['map_image', './img/geo.webp']
|
||||
]);
|
||||
|
||||
const licIcon = '<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm-2 16l-5-5 1.41-1.41L10 14.17l7.59-7.59L18 9l-8 8z"/>';
|
||||
await runEach('INSERT INTO licenses (svg_icon, title, description, sort_order) VALUES (?, ?, ?, ?)', [
|
||||
[licIcon, 'СРО — допуск к работам на опасных объектах', 'Свидетельство о допуске к работам на опасных производственных объектах нефтегазового комплекса.', 0],
|
||||
[licIcon, 'Членство в НОСТРОЙ', 'Действующее членство в Национальном объединении строителей — допуск к строительным работам.', 1],
|
||||
['<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 14l-5-5 1.41-1.41L12 14.17l7.59-7.59L21 8l-9 9z"/>', 'Сертификат ISO 9001 (качество)', 'Международный стандарт системы менеджмента качества.', 2],
|
||||
['<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>', 'Сертификат OHSAS 18001 (охрана труда)', 'Система менеджмента охраны труда и промышленной безопасности.', 3],
|
||||
['<path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/>', 'Договор ДМС для сотрудников', 'Добровольное медицинское страхование всех сотрудников.', 4],
|
||||
['<path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 7V3.5L18.5 9H13zM6 20V4h5v7h7v9H6z"/>', 'Лицензия на заготовку и переработку лома', 'Лицензия на заготовку, хранение и переработку лома черных металлов.', 5]
|
||||
]);
|
||||
|
||||
await runEach('INSERT INTO license_gallery (title, description, image_path, sort_order) VALUES (?, ?, ?, ?)', [
|
||||
['Лицензия на отходы I–IV класса опасности', '№ (66) -6527 - СТО/П от 21 мая 2019 г.', 'license-otxody.webp', 0],
|
||||
['Лицензия на лом черных и цветных металлов', '№ ЛМ 000092 от 31 октября 2018 г.', 'license-lom.webp', 1]
|
||||
]);
|
||||
|
||||
await runEach('INSERT INTO public_activities (svg_icon, title, description, sort_order) VALUES (?, ?, ?, ?)', [
|
||||
['<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>', 'Спонсорство', 'Поддерживаем спортивные, культурные и общественные мероприятия в регионах присутствия. Помогаем развитию местных сообществ.', 0],
|
||||
['<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>', 'Благотворительность', 'Оказываем адресную помощь детским домам, ветеранским и общественным организациям. Участвуем в социальных проектах.', 1],
|
||||
['<path d="M17 8C8 10 5.9 16.2 3 19l-.47.74A7.02 7.02 0 004.5 22.5C7.35 21.83 9.76 19.7 11.85 17.61 14.64 14.79 18.35 11.36 17 8zM10.05 16.81c-.73.73-1.93 1.1-2.94.63-1.03-.48-1.22-1.76-.54-2.67.62-.83 1.84-1.13 2.96-.69.63.25 1.12.69 1.48 1.15l-.96 1.58zM21.19 2.09c-.45-.5-1.15-.66-1.77-.42l-3.02 1.17 3.76 3.76 1.17-3.02c.24-.62.08-1.32-.42-1.77l.28.28z"/>', 'Экология', 'Участвуем в экологических акциях, субботниках и проектах по озеленению. Следим за снижением воздействия на окружающую среду.', 2],
|
||||
['<path d="M5 13.18v4L12 21l7-3.82v-4L12 17l-7-3.82zM12 3L1 9l11 6 9-4.91V17h2V9L12 3z"/>', 'Поддержка образования', 'Сотрудничаем с профильными вузами и колледжами. Организуем практику для студентов, участвуем в ярмарках вакансий.', 3]
|
||||
]);
|
||||
|
||||
await runEach('INSERT INTO activity_gallery (title, description, image_path, sort_order) VALUES (?, ?, ?, ?)', [
|
||||
['Благодарственное письмо', 'За вклад в развитие региона', 'thanks-1.webp', 0],
|
||||
['Благодарственное письмо', 'За благотворительную деятельность', 'thanks-2.webp', 1]
|
||||
]);
|
||||
|
||||
await runEach('INSERT INTO partners (name, logo_path, sort_order) VALUES (?, ?, ?)', [
|
||||
['Газпром', 'газпром.webp', 0], ['Роснефть', 'роснефть.webp', 1],
|
||||
['Сибур', 'сибур.webp', 2], ['Сургутнефтегаз', 'сургутнефтегаз.webp', 3],
|
||||
['Транснефть', 'транснефть.webp', 4]
|
||||
]);
|
||||
|
||||
await runEach('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)', [
|
||||
['contact_phone_800', '8 (800) 555-29-31'], ['contact_phone_ekb', '8 (343) 237-25-66'],
|
||||
['contact_email', 'sts_zakaz@mail.ru'], ['contact_address', 'г. Екатеринбург, ул. Московская, 11, оф. 702'],
|
||||
['contact_work_hours', 'Пн–Пт: 9:00 – 18:00|Сб: 10:00 – 15:00'], ['contact_map_image', './img/secret.webp'],
|
||||
['footer_text', '© {year} ООО «СТС-Авто». Все права защищены. 16+'],
|
||||
['form_title', 'Оставить заявку'], ['form_subtitle', 'Заполните форму и мы свяжемся с вами в ближайшее время'],
|
||||
['section_services_subtitle', 'Полный спектр работ по демонтажу и утилизации трубопроводной инфраструктуры. Работаем на опасных производственных объектах с собственным парком спецтехники.'],
|
||||
['section_schemes_subtitle', 'Мы разработали гибкие схемы работы, позволяющие выбрать оптимальное решение с учётом ваших целей и бюджета.'],
|
||||
['section_process_subtitle', 'Прозрачный и отлаженный процесс от первого обращения до сдачи объекта. Каждый этап контролируется инженерами компании.'],
|
||||
['section_equipment_subtitle', 'Собственный парк из более чем 80 единиц современной спецтехники ведущих мировых производителей. Возраст всей техники — не старше 5 лет.'],
|
||||
['section_licenses_subtitle', 'Все необходимые допуски и сертификаты для выполнения работ на опасных производственных объектах. Полная прозрачность и соответствие требованиям законодательства РФ.'],
|
||||
['section_activity_subtitle', 'Компания «СТС-Авто» активно участвует в социальной и общественной жизни регионов присутствия.'],
|
||||
['section_partners_subtitle', 'Сотрудничаем с ведущими компаниями нефтегазового сектора.'],
|
||||
['section_contacts_subtitle', 'Оставьте заявку или позвоните — мы оперативно ответим на ваши вопросы и рассчитаем стоимость работ.']
|
||||
]);
|
||||
}
|
||||
|
||||
function getDb() {
|
||||
return db;
|
||||
}
|
||||
|
||||
module.exports = { init, getDb };
|
||||
116
modules/equipment/index.js
Normal file
116
modules/equipment/index.js
Normal file
@@ -0,0 +1,116 @@
|
||||
const { createCrudRoutes } = require('../database/crud');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const sharp = require('sharp');
|
||||
|
||||
let db;
|
||||
|
||||
function init(database) { db = database; }
|
||||
|
||||
function setupRoutes(app, auth) {
|
||||
const uploadDir = path.join(__dirname, '..', '..', 'data', 'equipment_images');
|
||||
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: uploadDir,
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, 'equip_' + Date.now() + '_' + Math.round(Math.random() * 1000) + ext);
|
||||
}
|
||||
});
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowed = ['.jpg', '.jpeg', '.png', '.webp', '.gif'];
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
cb(null, allowed.includes(ext));
|
||||
}
|
||||
});
|
||||
|
||||
async function convertToWebp(filePath, fname) {
|
||||
const ext = path.extname(fname).toLowerCase();
|
||||
if (ext === '.webp') return fname;
|
||||
try {
|
||||
const webpName = path.basename(fname, ext) + '.webp';
|
||||
await sharp(filePath).webp({ quality: 85 }).toFile(path.join(uploadDir, webpName));
|
||||
fs.unlinkSync(filePath);
|
||||
return webpName;
|
||||
} catch (e) { return fname; }
|
||||
}
|
||||
|
||||
app.post('/api/admin/equipment/upload', auth.authenticateToken, auth.requireAdmin, upload.single('image'), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'Файл не загружен' });
|
||||
const fname = await convertToWebp(req.file.path, req.file.filename);
|
||||
res.json({ path: fname, url: `/data/equipment_images/${fname}` });
|
||||
});
|
||||
|
||||
app.post('/api/admin/equipment/gallery/upload', auth.authenticateToken, auth.requireAdmin, upload.array('images', 10), async (req, res) => {
|
||||
if (!req.files || req.files.length === 0) return res.status(400).json({ error: 'Файлы не загружены' });
|
||||
const results = [];
|
||||
for (const file of req.files) {
|
||||
const fname = await convertToWebp(file.path, file.filename);
|
||||
results.push({ path: fname, url: `/data/equipment_images/${fname}` });
|
||||
}
|
||||
res.json(results);
|
||||
});
|
||||
|
||||
app.get('/api/admin/equipment/:id/gallery', auth.authenticateToken, auth.requireAdmin, (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) return res.status(400).json({ error: 'Неверный ID' });
|
||||
db.all(`SELECT * FROM equipment_gallery WHERE equipment_id = ? ORDER BY sort_order`, [id], (err, rows) => {
|
||||
if (err) return res.status(500).json({ error: 'Ошибка БД' });
|
||||
res.json(rows);
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/admin/equipment/:id/gallery', auth.authenticateToken, auth.requireAdmin, (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) return res.status(400).json({ error: 'Неверный ID' });
|
||||
const { image_path, sort_order } = req.body;
|
||||
if (!image_path) return res.status(400).json({ error: 'Не указан путь к изображению' });
|
||||
const stmt = db.prepare(`INSERT INTO equipment_gallery (equipment_id, image_path, sort_order) VALUES (?, ?, ?)`);
|
||||
stmt.run(id, image_path, sort_order || 0, function(err) {
|
||||
if (err) return res.status(500).json({ error: 'Ошибка создания' });
|
||||
db.get(`SELECT * FROM equipment_gallery WHERE id = ?`, [this.lastID], (e2, row) => {
|
||||
if (e2) return res.status(500).json({});
|
||||
res.status(201).json(row);
|
||||
});
|
||||
});
|
||||
stmt.finalize();
|
||||
});
|
||||
|
||||
app.put('/api/admin/equipment/gallery/:imageId', auth.authenticateToken, auth.requireAdmin, (req, res) => {
|
||||
const imageId = parseInt(req.params.imageId);
|
||||
if (isNaN(imageId)) return res.status(400).json({ error: 'Неверный ID' });
|
||||
const { image_path, sort_order } = req.body;
|
||||
const stmt = db.prepare(`UPDATE equipment_gallery SET image_path = COALESCE(?, image_path), sort_order = COALESCE(?, sort_order) WHERE id = ?`);
|
||||
stmt.run(image_path || null, sort_order != null ? sort_order : null, imageId, function(err) {
|
||||
if (err) return res.status(500).json({ error: 'Ошибка обновления' });
|
||||
if (this.changes === 0) return res.status(404).json({ error: 'Не найдено' });
|
||||
db.get(`SELECT * FROM equipment_gallery WHERE id = ?`, [imageId], (e2, row) => {
|
||||
res.json(row);
|
||||
});
|
||||
});
|
||||
stmt.finalize();
|
||||
});
|
||||
|
||||
app.delete('/api/admin/equipment/gallery/:imageId', auth.authenticateToken, auth.requireAdmin, (req, res) => {
|
||||
const imageId = parseInt(req.params.imageId);
|
||||
if (isNaN(imageId)) return res.status(400).json({ error: 'Неверный ID' });
|
||||
const stmt = db.prepare(`DELETE FROM equipment_gallery WHERE id = ?`);
|
||||
stmt.run(imageId, function(err) {
|
||||
if (err) return res.status(500).json({ error: 'Ошибка удаления' });
|
||||
if (this.changes === 0) return res.status(404).json({ error: 'Не найдено' });
|
||||
res.json({ message: 'Удалено' });
|
||||
});
|
||||
stmt.finalize();
|
||||
});
|
||||
|
||||
createCrudRoutes(app, auth, 'equipment', {
|
||||
fields: ['name', 'brand', 'image_path', 'qty', 'specs', 'age_badge', 'sort_order', 'is_active']
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { init, setupRoutes };
|
||||
32
modules/geography/index.js
Normal file
32
modules/geography/index.js
Normal file
@@ -0,0 +1,32 @@
|
||||
const { createCrudRoutes } = require('../database/crud');
|
||||
|
||||
let db;
|
||||
function init(database) { db = database; }
|
||||
|
||||
function setupRoutes(app, auth) {
|
||||
app.get('/api/admin/geo-settings', auth.authenticateToken, auth.requireAdmin, (req, res) => {
|
||||
db.all(`SELECT * FROM geo_settings ORDER BY id`, (err, rows) => {
|
||||
if (err) return res.status(500).json({ error: 'Ошибка БД' });
|
||||
const obj = {};
|
||||
rows.forEach(r => { obj[r.key] = r.value; });
|
||||
res.json(obj);
|
||||
});
|
||||
});
|
||||
|
||||
app.put('/api/admin/geo-settings', auth.authenticateToken, auth.requireAdmin, (req, res) => {
|
||||
const entries = req.body;
|
||||
if (!entries || typeof entries !== 'object') return res.status(400).json({ error: 'Неверные данные' });
|
||||
const stmt = db.prepare(`UPDATE geo_settings SET value = ? WHERE key = ?`);
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
stmt.run(value, key);
|
||||
}
|
||||
stmt.finalize();
|
||||
res.json({ message: 'Обновлено' });
|
||||
});
|
||||
|
||||
createCrudRoutes(app, auth, 'geo_regions', {
|
||||
fields: ['name', 'sort_order']
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { init, setupRoutes };
|
||||
33
modules/hero/index.js
Normal file
33
modules/hero/index.js
Normal file
@@ -0,0 +1,33 @@
|
||||
const { createCrudRoutes } = require('../database/crud');
|
||||
|
||||
let db;
|
||||
function init(database) { db = database; }
|
||||
|
||||
function setupRoutes(app, auth) {
|
||||
app.get('/api/admin/hero-settings', auth.authenticateToken, auth.requireAdmin, (req, res) => {
|
||||
db.all(`SELECT * FROM hero_settings ORDER BY id`, (err, rows) => {
|
||||
if (err) return res.status(500).json({ error: 'Ошибка БД' });
|
||||
const obj = {};
|
||||
rows.forEach(r => { obj[r.key] = r.value; });
|
||||
res.json(obj);
|
||||
});
|
||||
});
|
||||
|
||||
app.put('/api/admin/hero-settings', auth.authenticateToken, auth.requireAdmin, (req, res) => {
|
||||
const entries = req.body;
|
||||
if (!entries || typeof entries !== 'object') return res.status(400).json({ error: 'Неверные данные' });
|
||||
const stmt = db.prepare(`UPDATE hero_settings SET value = ? WHERE key = ?`);
|
||||
let count = 0;
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
stmt.run(value, key, function() { count++; });
|
||||
}
|
||||
stmt.finalize();
|
||||
res.json({ message: 'Обновлено', updated: count });
|
||||
});
|
||||
|
||||
createCrudRoutes(app, auth, 'hero_stats', {
|
||||
fields: ['label', 'value', 'is_dynamic', 'sort_order']
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { init, setupRoutes };
|
||||
45
modules/leads/index.js
Normal file
45
modules/leads/index.js
Normal file
@@ -0,0 +1,45 @@
|
||||
let db;
|
||||
|
||||
function init(database) { db = database; }
|
||||
|
||||
function setupRoutes(app, auth) {
|
||||
const { authenticateToken, requireAdmin } = auth;
|
||||
|
||||
app.get('/api/admin/leads', authenticateToken, requireAdmin, (req, res) => {
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = 20;
|
||||
const offset = (page - 1) * limit;
|
||||
const search = req.query.search || '';
|
||||
|
||||
let where = '';
|
||||
const params = [];
|
||||
if (search) {
|
||||
where = `WHERE name LIKE ? OR phone LIKE ? OR message LIKE ?`;
|
||||
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
|
||||
}
|
||||
|
||||
db.get(`SELECT COUNT(*) as total FROM leads ${where}`, params, (err, countRow) => {
|
||||
if (err) return res.status(500).json({ error: 'Ошибка БД' });
|
||||
const total = countRow.total;
|
||||
db.all(`SELECT * FROM leads ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`,
|
||||
[...params, limit, offset], (err2, rows) => {
|
||||
if (err2) return res.status(500).json({ error: 'Ошибка БД' });
|
||||
res.json({ data: rows, total, page, totalPages: Math.ceil(total / limit) });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
app.delete('/api/admin/leads/:id', authenticateToken, requireAdmin, (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) return res.status(400).json({ error: 'Неверный ID' });
|
||||
const stmt = db.prepare(`DELETE FROM leads WHERE id = ?`);
|
||||
stmt.run(id, function(err) {
|
||||
if (err) return res.status(500).json({ error: 'Ошибка удаления' });
|
||||
if (this.changes === 0) return res.status(404).json({ error: 'Не найдено' });
|
||||
res.json({ message: 'Заявка удалена' });
|
||||
});
|
||||
stmt.finalize();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { init, setupRoutes };
|
||||
55
modules/licenses/index.js
Normal file
55
modules/licenses/index.js
Normal file
@@ -0,0 +1,55 @@
|
||||
const { createCrudRoutes } = require('../database/crud');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const sharp = require('sharp');
|
||||
|
||||
let db;
|
||||
|
||||
function init(database) { db = database; }
|
||||
|
||||
function setupRoutes(app, auth) {
|
||||
const uploadDir = path.join(__dirname, '..', '..', 'data', 'license_images');
|
||||
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: uploadDir,
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, 'lic_' + Date.now() + ext);
|
||||
}
|
||||
});
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowed = ['.jpg', '.jpeg', '.png', '.webp'];
|
||||
cb(null, allowed.includes(path.extname(file.originalname).toLowerCase()));
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/admin/licenses/upload', auth.authenticateToken, auth.requireAdmin, upload.single('image'), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'Файл не загружен' });
|
||||
let fname = req.file.filename;
|
||||
const ext = path.extname(fname).toLowerCase();
|
||||
if (ext !== '.webp') {
|
||||
try {
|
||||
const webpName = path.basename(fname, ext) + '.webp';
|
||||
await sharp(req.file.path).webp({ quality: 85 }).toFile(path.join(uploadDir, webpName));
|
||||
fs.unlinkSync(req.file.path);
|
||||
fname = webpName;
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
res.json({ path: fname, url: `/data/license_images/${fname}` });
|
||||
});
|
||||
|
||||
createCrudRoutes(app, auth, 'licenses', {
|
||||
fields: ['svg_icon', 'title', 'description', 'sort_order']
|
||||
});
|
||||
|
||||
createCrudRoutes(app, auth, 'license_gallery', {
|
||||
fields: ['title', 'description', 'image_path', 'sort_order']
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { init, setupRoutes };
|
||||
51
modules/partners/index.js
Normal file
51
modules/partners/index.js
Normal file
@@ -0,0 +1,51 @@
|
||||
const { createCrudRoutes } = require('../database/crud');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const sharp = require('sharp');
|
||||
|
||||
let db;
|
||||
|
||||
function init(database) { db = database; }
|
||||
|
||||
function setupRoutes(app, auth) {
|
||||
const uploadDir = path.join(__dirname, '..', '..', 'data', 'partner_images');
|
||||
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: uploadDir,
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, 'prt_' + Date.now() + ext);
|
||||
}
|
||||
});
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 3 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowed = ['.jpg', '.jpeg', '.png', '.webp', '.svg'];
|
||||
cb(null, allowed.includes(path.extname(file.originalname).toLowerCase()));
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/admin/partners/upload', auth.authenticateToken, auth.requireAdmin, upload.single('image'), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'Файл не загружен' });
|
||||
let fname = req.file.filename;
|
||||
const ext = path.extname(fname).toLowerCase();
|
||||
if (ext !== '.webp' && ext !== '.svg') {
|
||||
try {
|
||||
const webpName = path.basename(fname, ext) + '.webp';
|
||||
await sharp(req.file.path).webp({ quality: 85 }).toFile(path.join(uploadDir, webpName));
|
||||
fs.unlinkSync(req.file.path);
|
||||
fname = webpName;
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
res.json({ path: fname, url: `/data/partner_images/${fname}` });
|
||||
});
|
||||
|
||||
createCrudRoutes(app, auth, 'partners', {
|
||||
fields: ['name', 'logo_path', 'sort_order', 'is_active']
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { init, setupRoutes };
|
||||
12
modules/processSteps/index.js
Normal file
12
modules/processSteps/index.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const { createCrudRoutes } = require('../database/crud');
|
||||
|
||||
let db;
|
||||
function init(database) { db = database; }
|
||||
|
||||
function setupRoutes(app, auth) {
|
||||
createCrudRoutes(app, auth, 'process_steps', {
|
||||
fields: ['step_number', 'title', 'description', 'sort_order']
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { init, setupRoutes };
|
||||
12
modules/services/index.js
Normal file
12
modules/services/index.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const { createCrudRoutes } = require('../database/crud');
|
||||
|
||||
let db;
|
||||
function init(database) { db = database; }
|
||||
|
||||
function setupRoutes(app, auth) {
|
||||
createCrudRoutes(app, auth, 'services', {
|
||||
fields: ['svg_icon', 'title', 'description', 'items', 'is_large', 'sort_order', 'is_active']
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { init, setupRoutes };
|
||||
29
modules/settings/index.js
Normal file
29
modules/settings/index.js
Normal file
@@ -0,0 +1,29 @@
|
||||
let db;
|
||||
|
||||
function init(database) { db = database; }
|
||||
|
||||
function setupRoutes(app, auth) {
|
||||
const { authenticateToken, requireAdmin } = auth;
|
||||
|
||||
app.get('/api/admin/settings', authenticateToken, requireAdmin, (req, res) => {
|
||||
db.all(`SELECT * FROM settings ORDER BY id`, (err, rows) => {
|
||||
if (err) return res.status(500).json({ error: 'Ошибка БД' });
|
||||
const obj = {};
|
||||
rows.forEach(r => { obj[r.key] = r.value; });
|
||||
res.json(obj);
|
||||
});
|
||||
});
|
||||
|
||||
app.put('/api/admin/settings', authenticateToken, requireAdmin, (req, res) => {
|
||||
const entries = req.body;
|
||||
if (!entries || typeof entries !== 'object') return res.status(400).json({ error: 'Неверные данные' });
|
||||
const stmt = db.prepare(`UPDATE settings SET value = ? WHERE key = ?`);
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
stmt.run(String(value), key);
|
||||
}
|
||||
stmt.finalize();
|
||||
res.json({ message: 'Обновлено' });
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { init, setupRoutes };
|
||||
86
modules/siteData/index.js
Normal file
86
modules/siteData/index.js
Normal file
@@ -0,0 +1,86 @@
|
||||
let db;
|
||||
|
||||
function init(database) { db = database; }
|
||||
|
||||
function loadEquipmentGallery(data) {
|
||||
return new Promise((resolve) => {
|
||||
db.all(`SELECT * FROM equipment_gallery ORDER BY equipment_id, sort_order`, (err, rows) => {
|
||||
if (err || !rows) { data.equipment_gallery = []; resolve(); return; }
|
||||
data.equipment_gallery = rows;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setupRoutes(app) {
|
||||
app.get('/api/site-data', (req, res) => {
|
||||
const data = {};
|
||||
|
||||
function loadTable(table, key) {
|
||||
return new Promise((resolve) => {
|
||||
db.all(`SELECT * FROM ${table} ORDER BY sort_order`, (err, rows) => {
|
||||
if (err) { data[key] = []; resolve(); return; }
|
||||
if (table === 'equipment' || table === 'partners') {
|
||||
rows = rows.filter(r => r.is_active == 1);
|
||||
}
|
||||
if (table === 'services' || table === 'work_schemes') {
|
||||
rows = rows.filter(r => r.is_active == 1);
|
||||
}
|
||||
data[key] = rows;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function loadSettings(table, key, transformFn) {
|
||||
return new Promise((resolve) => {
|
||||
db.all(`SELECT * FROM ${table} ORDER BY id`, (err, rows) => {
|
||||
if (err) { data[key] = {}; resolve(); return; }
|
||||
if (transformFn) {
|
||||
data[key] = transformFn(rows);
|
||||
} else {
|
||||
const obj = {};
|
||||
rows.forEach(r => { obj[r.key] = r.value; });
|
||||
data[key] = obj;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
loadSettings('hero_settings', 'hero'),
|
||||
loadTable('hero_stats', 'hero_stats'),
|
||||
loadSettings('about_settings', 'about'),
|
||||
loadTable('about_features', 'about_features'),
|
||||
loadTable('services', 'services'),
|
||||
loadTable('work_schemes', 'work_schemes'),
|
||||
loadTable('process_steps', 'process_steps'),
|
||||
loadTable('equipment', 'equipment'),
|
||||
loadEquipmentGallery(data),
|
||||
loadTable('geo_regions', 'geo_regions'),
|
||||
loadSettings('geo_settings', 'geo'),
|
||||
loadTable('licenses', 'licenses'),
|
||||
loadTable('license_gallery', 'license_gallery'),
|
||||
loadTable('public_activities', 'public_activities'),
|
||||
loadTable('activity_gallery', 'activity_gallery'),
|
||||
loadTable('partners', 'partners'),
|
||||
loadSettings('settings', 'settings')
|
||||
]).then(() => {
|
||||
res.json(data);
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/leads', (req, res) => {
|
||||
const { name, phone, message } = req.body;
|
||||
if (!name || !phone) return res.status(400).json({ error: 'Имя и телефон обязательны' });
|
||||
const stmt = db.prepare(`INSERT INTO leads (name, phone, message) VALUES (?, ?, ?)`);
|
||||
stmt.run(name.trim(), phone.trim(), (message || '').trim(), function(err) {
|
||||
if (err) return res.status(500).json({ error: 'Ошибка БД' });
|
||||
res.status(201).json({ id: this.lastID, message: 'Заявка сохранена' });
|
||||
});
|
||||
stmt.finalize();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { init, setupRoutes };
|
||||
84
modules/users/index.js
Normal file
84
modules/users/index.js
Normal file
@@ -0,0 +1,84 @@
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
let db;
|
||||
|
||||
function init(database) { db = database; }
|
||||
|
||||
function setupRoutes(app, auth) {
|
||||
const { authenticateToken, requireAdmin } = auth;
|
||||
|
||||
app.get('/api/admin/users', authenticateToken, requireAdmin, (req, res) => {
|
||||
db.all(`SELECT id, login, full_name, role, created_at FROM users ORDER BY id`, (err, rows) => {
|
||||
if (err) return res.status(500).json({ error: 'Ошибка БД' });
|
||||
res.json(rows);
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/admin/users', authenticateToken, requireAdmin, (req, res) => {
|
||||
const { login, password, full_name, role } = req.body;
|
||||
if (!login || !password) return res.status(400).json({ error: 'Логин и пароль обязательны' });
|
||||
const hash = bcrypt.hashSync(password, 10);
|
||||
const stmt = db.prepare(`INSERT INTO users (login, password_hash, full_name, role) VALUES (?, ?, ?, ?)`);
|
||||
stmt.run(login, hash, full_name || '', role || 'admin', function(err) {
|
||||
if (err) {
|
||||
if (err.message.includes('UNIQUE')) return res.status(409).json({ error: 'Пользователь с таким логином уже существует' });
|
||||
return res.status(500).json({ error: 'Ошибка создания' });
|
||||
}
|
||||
db.get(`SELECT id, login, full_name, role, created_at FROM users WHERE id = ?`, [this.lastID], (e2, row) => {
|
||||
res.status(201).json(row);
|
||||
});
|
||||
});
|
||||
stmt.finalize();
|
||||
});
|
||||
|
||||
app.put('/api/admin/users/:id', authenticateToken, requireAdmin, (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) return res.status(400).json({ error: 'Неверный ID' });
|
||||
const { login, password, full_name, role } = req.body;
|
||||
|
||||
if (password) {
|
||||
const hash = bcrypt.hashSync(password, 10);
|
||||
const stmt = db.prepare(`UPDATE users SET login = ?, password_hash = ?, full_name = ?, role = ? WHERE id = ?`);
|
||||
stmt.run(login, hash, full_name || '', role || 'admin', id, function(err) {
|
||||
if (err) return res.status(500).json({ error: 'Ошибка обновления' });
|
||||
if (this.changes === 0) return res.status(404).json({ error: 'Не найдено' });
|
||||
db.get(`SELECT id, login, full_name, role, created_at FROM users WHERE id = ?`, [id], (e2, row) => {
|
||||
res.json(row);
|
||||
});
|
||||
});
|
||||
stmt.finalize();
|
||||
} else {
|
||||
const stmt = db.prepare(`UPDATE users SET login = ?, full_name = ?, role = ? WHERE id = ?`);
|
||||
stmt.run(login, full_name || '', role || 'admin', id, function(err) {
|
||||
if (err) return res.status(500).json({ error: 'Ошибка обновления' });
|
||||
if (this.changes === 0) return res.status(404).json({ error: 'Не найдено' });
|
||||
db.get(`SELECT id, login, full_name, role, created_at FROM users WHERE id = ?`, [id], (e2, row) => {
|
||||
res.json(row);
|
||||
});
|
||||
});
|
||||
stmt.finalize();
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/admin/users/:id', authenticateToken, requireAdmin, (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) return res.status(400).json({ error: 'Неверный ID' });
|
||||
if (id === req.user.id) return res.status(400).json({ error: 'Нельзя удалить самого себя' });
|
||||
|
||||
const superadmin = process.env.ADMIN_USERNAME || 'admin';
|
||||
db.get(`SELECT login FROM users WHERE id = ?`, [id], (e1, row) => {
|
||||
if (row && row.login === superadmin) {
|
||||
return res.status(400).json({ error: 'Нельзя удалить суперадминистратора из .env' });
|
||||
}
|
||||
const stmt = db.prepare(`DELETE FROM users WHERE id = ?`);
|
||||
stmt.run(id, function(err) {
|
||||
if (err) return res.status(500).json({ error: 'Ошибка удаления' });
|
||||
if (this.changes === 0) return res.status(404).json({ error: 'Не найдено' });
|
||||
res.json({ message: 'Пользователь удалён' });
|
||||
});
|
||||
stmt.finalize();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { init, setupRoutes };
|
||||
12
modules/workSchemes/index.js
Normal file
12
modules/workSchemes/index.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const { createCrudRoutes } = require('../database/crud');
|
||||
|
||||
let db;
|
||||
function init(database) { db = database; }
|
||||
|
||||
function setupRoutes(app, auth) {
|
||||
createCrudRoutes(app, auth, 'work_schemes', {
|
||||
fields: ['svg_icon', 'title', 'description', 'items', 'sort_order', 'is_active']
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { init, setupRoutes };
|
||||
@@ -1,7 +1,10 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.2.0",
|
||||
"sharp": "^0.34.5",
|
||||
"sqlite3": "^6.0.1"
|
||||
},
|
||||
|
||||
989
public/admin.html
Normal file
989
public/admin.html
Normal file
@@ -0,0 +1,989 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Админ-панель | СТС-Авто</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700;800&family=Roboto+Condensed:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
|
||||
<style>
|
||||
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
|
||||
:root{--dark:#1a2a3a;--dark-deep:#0d1a27;--bg:#f5f2ed;--bg-white:#fff;--orange:#E67E22;--orange-dark:#D35400;--orange-light:#F39C12;--text:#1a2a3a;--text-gray:#5a6a7a;--border:#d5d0c8;--danger:#e74c3c;--success:#27AE60;--sidebar-w:260px}
|
||||
body{font-family:'Roboto Condensed',sans-serif;color:var(--text);background:var(--bg);display:flex;min-height:100vh}
|
||||
#login-page{display:flex;align-items:center;justify-content:center;width:100%;min-height:100vh;background:linear-gradient(135deg,#1a2a3a,#2c3e50)}
|
||||
.login-box{background:#fff;border-radius:16px;padding:40px 36px;width:100%;max-width:400px;box-shadow:0 20px 60px rgba(0,0,0,.3)}
|
||||
.login-box h2{font-family:'Montserrat',sans-serif;font-weight:800;font-size:1.6rem;margin-bottom:6px;color:var(--dark)}
|
||||
.login-box p{color:var(--text-gray);margin-bottom:24px;font-size:.93rem}
|
||||
.login-box input{width:100%;padding:12px 16px;border:1px solid var(--border);border-radius:8px;margin-bottom:14px;font-size:1rem;font-family:'Roboto Condensed',sans-serif;outline:none;transition:border .3s}
|
||||
.login-box input:focus{border-color:var(--orange)}
|
||||
.login-box .btn{width:100%;padding:14px;border:none;border-radius:8px;background:linear-gradient(135deg,var(--orange),var(--orange-dark));color:#fff;font-family:'Montserrat',sans-serif;font-weight:700;font-size:.95rem;text-transform:uppercase;letter-spacing:1px;cursor:pointer;transition:all .3s}
|
||||
.login-box .btn:hover{transform:translateY(-2px);box-shadow:0 8px 25px rgba(230,126,34,.4)}
|
||||
.login-error{color:var(--danger);font-size:.85rem;margin-top:10px;text-align:center;display:none}
|
||||
|
||||
#admin-panel{display:none;width:100%}
|
||||
.sidebar{position:fixed;top:0;left:0;bottom:0;width:var(--sidebar-w);background:var(--dark);border-right:3px solid var(--orange);overflow-y:auto;z-index:100;padding-bottom:20px}
|
||||
.sidebar-logo{padding:20px 20px 16px;border-bottom:1px solid rgba(255,255,255,.08);margin-bottom:12px}
|
||||
.sidebar-logo h3{font-family:'Montserrat',sans-serif;font-weight:800;font-size:1.05rem;color:#fff}
|
||||
.sidebar-logo span{color:var(--orange-light);font-size:.75rem;display:block;margin-top:2px}
|
||||
.sidebar-nav{list-style:none;padding:0 10px}
|
||||
.sidebar-nav li{margin-bottom:2px}
|
||||
.sidebar-nav a{display:flex;align-items:center;gap:10px;padding:10px 14px;border-radius:8px;color:#bdc3c7;font-family:'Montserrat',sans-serif;font-weight:600;font-size:.82rem;text-transform:uppercase;letter-spacing:.5px;text-decoration:none;transition:all .3s;cursor:pointer}
|
||||
.sidebar-nav a:hover{background:rgba(230,126,34,.1);color:#fff}
|
||||
.sidebar-nav a.active{background:linear-gradient(135deg,var(--orange),var(--orange-dark));color:#fff}
|
||||
.sidebar-nav a i{width:18px;text-align:center;font-size:.85rem}
|
||||
.sidebar-logout{border-top:1px solid rgba(255,255,255,.08);margin-top:12px;padding-top:12px}
|
||||
|
||||
.main{flex:1;margin-left:var(--sidebar-w);padding:20px 24px;min-height:100vh}
|
||||
.page-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px;flex-wrap:wrap;gap:12px}
|
||||
.page-header h1{font-family:'Montserrat',sans-serif;font-weight:800;font-size:1.5rem}
|
||||
.page-header h1 span{color:var(--orange)}
|
||||
.btn{display:inline-flex;align-items:center;gap:6px;padding:10px 20px;border-radius:6px;font-family:'Montserrat',sans-serif;font-weight:600;font-size:.8rem;text-transform:uppercase;letter-spacing:.8px;cursor:pointer;border:none;transition:all .3s;text-decoration:none}
|
||||
.btn-primary{background:linear-gradient(135deg,var(--orange),var(--orange-dark));color:#fff}
|
||||
.btn-primary:hover{transform:translateY(-1px);box-shadow:0 4px 15px rgba(230,126,34,.3)}
|
||||
.btn-danger{background:var(--danger);color:#fff}
|
||||
.btn-danger:hover{transform:translateY(-1px);box-shadow:0 4px 15px rgba(231,76,60,.3)}
|
||||
.btn-sm{padding:6px 12px;font-size:.7rem;letter-spacing:.5px}
|
||||
.btn-icon{background:none;border:none;cursor:pointer;padding:4px 6px;border-radius:4px;transition:all .2s;font-size:.9rem}
|
||||
.btn-icon.edit{color:var(--orange)}
|
||||
.btn-icon.delete{color:var(--danger)}
|
||||
|
||||
.table-wrap{background:#fff;border-radius:12px;border:1px solid var(--border);overflow:hidden}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
th{background:var(--bg);padding:12px 16px;text-align:left;font-family:'Montserrat',sans-serif;font-weight:700;font-size:.75rem;text-transform:uppercase;letter-spacing:.8px;color:var(--text-gray);border-bottom:2px solid var(--border)}
|
||||
td{padding:10px 16px;border-bottom:1px solid var(--border);font-size:.9rem;color:var(--text)}
|
||||
tr:last-child td{border-bottom:none}
|
||||
tr:hover td{background:rgba(230,126,34,.03)}
|
||||
td.actions{white-space:nowrap}
|
||||
.text-muted{color:var(--text-gray);font-size:.82rem}
|
||||
.badge{display:inline-block;padding:3px 10px;border-radius:50px;font-family:'Montserrat',sans-serif;font-weight:600;font-size:.7rem;text-transform:uppercase}
|
||||
.badge-success{background:rgba(39,174,96,.1);color:var(--success);border:1px solid rgba(39,174,96,.3)}
|
||||
.badge-warning{background:rgba(241,196,15,.1);color:#b7950b;border:1px solid rgba(241,196,15,.3)}
|
||||
.pagination{display:flex;gap:6px;justify-content:center;padding:16px;align-items:center}
|
||||
.pagination button{padding:6px 12px;border:1px solid var(--border);background:#fff;border-radius:4px;cursor:pointer;font-size:.85rem;transition:all .2s}
|
||||
.pagination button:hover{background:var(--orange);color:#fff;border-color:var(--orange)}
|
||||
.pagination button.active{background:var(--orange);color:#fff;border-color:var(--orange)}
|
||||
.pagination span{color:var(--text-gray);font-size:.85rem}
|
||||
.search-bar{margin-bottom:16px;display:flex;gap:10px}
|
||||
.search-bar input{padding:10px 16px;border:1px solid var(--border);border-radius:8px;font-size:.9rem;font-family:'Roboto Condensed',sans-serif;outline:none;flex:1;transition:border .3s}
|
||||
.search-bar input:focus{border-color:var(--orange)}
|
||||
|
||||
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:1000;display:flex;align-items:center;justify-content:center;opacity:0;visibility:hidden;transition:all .3s}
|
||||
.modal-overlay.open{opacity:1;visibility:visible}
|
||||
.modal{background:#fff;border-radius:16px;padding:28px 30px;width:90%;max-width:600px;max-height:85vh;overflow-y:auto;box-shadow:0 20px 60px rgba(0,0,0,.3);position:relative}
|
||||
.modal h3{font-family:'Montserrat',sans-serif;font-weight:800;font-size:1.2rem;margin-bottom:20px;color:var(--dark)}
|
||||
.modal-close{position:absolute;top:12px;right:16px;background:none;border:none;font-size:1.3rem;cursor:pointer;color:var(--text-gray);padding:4px 8px}
|
||||
.modal-close:hover{color:var(--danger)}
|
||||
.form-group2{margin-bottom:14px}
|
||||
.form-group2 label{display:block;font-family:'Montserrat',sans-serif;font-weight:700;font-size:.75rem;text-transform:uppercase;letter-spacing:.8px;color:var(--text-gray);margin-bottom:5px}
|
||||
.form-group2 input,.form-group2 textarea,.form-group2 select{width:100%;padding:10px 14px;border:1px solid var(--border);border-radius:6px;font-size:.9rem;font-family:'Roboto Condensed',sans-serif;outline:none;transition:border .3s}
|
||||
.form-group2 input:focus,.form-group2 textarea:focus,.form-group2 select:focus{border-color:var(--orange)}
|
||||
.form-group2 textarea{resize:vertical;min-height:80px}
|
||||
.form-group2 .help{font-size:.75rem;color:var(--text-gray);margin-top:3px}
|
||||
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||
.modal-actions{display:flex;gap:10px;justify-content:flex-end;margin-top:20px;padding-top:16px;border-top:1px solid var(--border)}
|
||||
.preview-img{max-width:200px;max-height:120px;border-radius:6px;margin:6px 0;border:1px solid var(--border)}
|
||||
|
||||
.toast-container{position:fixed;top:20px;right:20px;z-index:10000;display:flex;flex-direction:column;gap:8px}
|
||||
.toast{padding:14px 20px;border-radius:8px;color:#fff;font-size:.9rem;font-family:'Montserrat',sans-serif;font-weight:600;box-shadow:0 8px 25px rgba(0,0,0,.2);animation:slideIn .3s ease-out;max-width:380px}
|
||||
.toast-success{background:var(--success)}
|
||||
.toast-error{background:var(--danger)}
|
||||
@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
|
||||
|
||||
.settings-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px}
|
||||
.settings-card{background:#fff;border:1px solid var(--border);border-radius:12px;padding:20px 22px}
|
||||
.settings-card h3{font-family:'Montserrat',sans-serif;font-weight:700;font-size:.95rem;margin-bottom:14px;color:var(--orange-dark)}
|
||||
.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:16px;margin-bottom:24px}
|
||||
.stat-card{background:#fff;border:1px solid var(--border);border-radius:12px;padding:20px 22px}
|
||||
.stat-card .num{font-family:'Montserrat',sans-serif;font-weight:900;font-size:2rem;color:var(--orange)}
|
||||
.stat-card .lbl{font-size:.85rem;color:var(--text-gray);margin-top:4px}
|
||||
.stat-card i{font-size:1.4rem;color:var(--orange);margin-bottom:8px;display:block}
|
||||
@media (max-width:768px){.sidebar{width:200px}.main{margin-left:200px}.settings-grid{grid-template-columns:1fr}.form-row{grid-template-columns:1fr}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="login-page">
|
||||
<div class="login-box">
|
||||
<h2>СТС-Авто</h2>
|
||||
<p>Административная панель</p>
|
||||
<input type="text" id="loginInput" placeholder="Логин">
|
||||
<input type="password" id="passInput" placeholder="Пароль">
|
||||
<button class="btn" onclick="doLogin()">Войти</button>
|
||||
<div class="login-error" id="loginError">Неверный логин или пароль</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="admin-panel">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-logo"><h3>СТС-Авто</h3><span>Админ-панель</span></div>
|
||||
<ul class="sidebar-nav">
|
||||
<li><a data-tab="dashboard" class="active"><i class="fas fa-chart-pie"></i> Дашборд</a></li>
|
||||
<li><a data-tab="hero"><i class="fas fa-image"></i> Hero</a></li>
|
||||
<li><a data-tab="about"><i class="fas fa-building"></i> О компании</a></li>
|
||||
<li><a data-tab="services"><i class="fas fa-tools"></i> Услуги</a></li>
|
||||
<li><a data-tab="schemes"><i class="fas fa-handshake"></i> Схемы работы</a></li>
|
||||
<li><a data-tab="process"><i class="fas fa-list-ol"></i> Этапы работы</a></li>
|
||||
<li><a data-tab="equipment"><i class="fas fa-truck"></i> Спецтехника</a></li>
|
||||
<li><a data-tab="geography"><i class="fas fa-map-marker-alt"></i> География</a></li>
|
||||
<li><a data-tab="licenses"><i class="fas fa-certificate"></i> Лицензии</a></li>
|
||||
<li><a data-tab="activities"><i class="fas fa-star"></i> Деятельность</a></li>
|
||||
<li><a data-tab="partners"><i class="fas fa-handshake"></i> Партнёры</a></li>
|
||||
<li><a data-tab="settings"><i class="fas fa-cog"></i> Настройки</a></li>
|
||||
<li><a data-tab="leads"><i class="fas fa-envelope"></i> Заявки</a></li>
|
||||
<li><a data-tab="users"><i class="fas fa-users"></i> Пользователи</a></li>
|
||||
<li class="sidebar-logout"><a onclick="logout()" style="color:var(--danger)"><i class="fas fa-sign-out-alt"></i> Выход</a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
<div class="main" id="mainContent"></div>
|
||||
</div>
|
||||
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
<div class="modal-overlay" id="modalOverlay"><div class="modal" id="modalContent"></div></div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
'use strict';
|
||||
var BASE = '/api/admin';
|
||||
var token = localStorage.getItem('adminToken');
|
||||
var user = null;
|
||||
var currentTab = 'dashboard';
|
||||
var _rows = []; // current rows data
|
||||
var _table = ''; // current table name
|
||||
var _columns = []; // current columns config
|
||||
var _fields = []; // current form fields
|
||||
var _uploadPrefix = ''; // upload API prefix
|
||||
var _tabId = 0; // tab switch guard
|
||||
|
||||
var headings = {
|
||||
dashboard:'Дашборд', hero:'Hero-секция', about:'О компании', services:'Услуги',
|
||||
schemes:'Схемы работы', process:'Этапы работы', equipment:'Спецтехника',
|
||||
geography:'География', licenses:'Лицензии', activities:'Общественная деятельность',
|
||||
partners:'Партнёры', settings:'Настройки сайта', leads:'Заявки', users:'Пользователи'
|
||||
};
|
||||
|
||||
if (token) { verifyAndEnter(); }
|
||||
|
||||
function verifyAndEnter() {
|
||||
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
.then(function(r) { if (!r.ok) throw new Error(); return r.json(); })
|
||||
.then(function(u) { user = u; enterPanel(); })
|
||||
.catch(function() { token = null; localStorage.removeItem('adminToken'); });
|
||||
}
|
||||
|
||||
function doLogin() {
|
||||
var login = document.getElementById('loginInput').value.trim();
|
||||
var pass = document.getElementById('passInput').value.trim();
|
||||
if (!login || !pass) return;
|
||||
fetch('/api/auth/login', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ login: login, password: pass })
|
||||
}).then(function(r) { return r.json(); }).then(function(d) {
|
||||
if (d.error) { document.getElementById('loginError').style.display='block'; return; }
|
||||
token = d.token; user = d.user;
|
||||
localStorage.setItem('adminToken', token);
|
||||
enterPanel();
|
||||
}).catch(function() { document.getElementById('loginError').style.display='block'; });
|
||||
}
|
||||
|
||||
function h(title) {
|
||||
return '<div class="page-header"><h1><span>' + headings[currentTab] + '</span></h1></div>';
|
||||
}
|
||||
|
||||
function toast(msg, type) {
|
||||
type = type || 'success';
|
||||
var t = document.createElement('div');
|
||||
t.className = 'toast toast-' + type;
|
||||
t.textContent = msg;
|
||||
document.getElementById('toastContainer').appendChild(t);
|
||||
setTimeout(function() { t.remove(); }, 4000);
|
||||
}
|
||||
|
||||
function api(url, opts) {
|
||||
opts = opts || {};
|
||||
return fetch(url, {
|
||||
method: opts.method || 'GET',
|
||||
headers: Object.assign({ 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, opts.headers || {}),
|
||||
body: opts.body || undefined
|
||||
}).then(function(r) { return r.json().then(function(d) { return { ok: r.ok, data: d }; }); });
|
||||
}
|
||||
|
||||
function openModal(title, renderFn) {
|
||||
var overlay = document.getElementById('modalOverlay');
|
||||
var content = document.getElementById('modalContent');
|
||||
content.innerHTML = '<button class="modal-close" onclick="closeModal()">×</button><h3>' + title + '</h3>';
|
||||
renderFn(content);
|
||||
overlay.classList.add('open');
|
||||
overlay.onclick = function(e) { if (e.target === overlay) closeModal(); };
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('modalOverlay').classList.remove('open');
|
||||
}
|
||||
|
||||
function esc(s) { return String(s||'').replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
|
||||
function rendCol(v, row, col) {
|
||||
if (col.render) return col.render(v, row);
|
||||
if (col.type === 'json' && v) {
|
||||
try {
|
||||
var arr = JSON.parse(v);
|
||||
var s = arr.map(function(i) { return typeof i === 'string' ? i : (i.label + ': ' + i.value); }).join(', ');
|
||||
return s.length > 80 ? s.substring(0, 77) + '...' : s;
|
||||
} catch(e) { return String(v).substring(0, 60); }
|
||||
}
|
||||
return v != null ? String(v) : '';
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
if (!_rows || _rows.length === 0) return '<p class="text-muted">Нет данных</p>';
|
||||
var html = '<div class="table-wrap"><table><thead><tr>';
|
||||
_columns.forEach(function(c) { html += '<th>' + c.label + '</th>'; });
|
||||
html += '<th>Действия</th></tr></thead><tbody>';
|
||||
_rows.forEach(function(row) {
|
||||
html += '<tr>';
|
||||
_columns.forEach(function(c) { html += '<td>' + rendCol(row[c.field], row, c) + '</td>'; });
|
||||
html += '<td class="actions">';
|
||||
html += '<button class="btn-icon edit" data-action="edit" data-id="' + row.id + '"><i class="fas fa-edit"></i></button>';
|
||||
html += '<button class="btn-icon delete" data-action="delete" data-id="' + row.id + '"><i class="fas fa-trash"></i></button>';
|
||||
html += '</td></tr>';
|
||||
});
|
||||
html += '</tbody></table></div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function jsonEditor(target, initialValue) {
|
||||
var val;
|
||||
try { val = JSON.parse(initialValue || '[]'); } catch(e) { val = []; }
|
||||
var html = '<div class="json-editor" style="display:flex;flex-direction:column;gap:6px">';
|
||||
var isSpec = val.length > 0 && typeof val[0] === 'object';
|
||||
if (isSpec) {
|
||||
val.forEach(function(item) {
|
||||
html += '<div style="display:flex;gap:6px"><input placeholder="Название" oninput="winUpdateJson(this)" value="' + esc(item.label||'') + '" style="flex:1"><input placeholder="Значение" oninput="winUpdateJson(this)" value="' + esc(item.value||'') + '" style="flex:1"><button type="button" class="btn btn-danger btn-sm" onclick="this.parentElement.remove();var ed=this.closest(\'.json-editor\');ed.querySelector(\'input[type=hidden]\').value=JSON.stringify(winCollectPairs(ed))">×</button></div>';
|
||||
});
|
||||
} else {
|
||||
val.forEach(function(item) {
|
||||
html += '<div style="display:flex;gap:6px"><input oninput="winUpdateJson(this)" value="' + esc(String(item)) + '" style="flex:1"><button type="button" class="btn btn-danger btn-sm" onclick="this.parentElement.remove();var ed=this.closest(\'.json-editor\');ed.querySelector(\'input[type=hidden]\').value=JSON.stringify(winCollectList(ed))">×</button></div>';
|
||||
});
|
||||
}
|
||||
html += '<button type="button" class="btn btn-sm" onclick="winAddJsonRow(this)">+ Добавить</button>';
|
||||
html += '<input type="hidden" id="' + target + '" value="' + esc(JSON.stringify(val)) + '"></div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
window.winCollectPairs = function(editor) {
|
||||
var inputs = editor.querySelectorAll('input:not([type=hidden])');
|
||||
var arr = [];
|
||||
for (var i = 0; i < inputs.length; i += 2) {
|
||||
if (inputs[i].value || (inputs[i+1] && inputs[i+1].value)) {
|
||||
arr.push({ label: inputs[i].value, value: inputs[i+1] ? inputs[i+1].value : '' });
|
||||
}
|
||||
}
|
||||
return arr;
|
||||
};
|
||||
|
||||
window.winCollectList = function(editor) {
|
||||
var inputs = editor.querySelectorAll('input:not([type=hidden])');
|
||||
var arr = [];
|
||||
inputs.forEach(function(inp) { if (inp.value) arr.push(inp.value); });
|
||||
return arr;
|
||||
};
|
||||
|
||||
window.winUpdateJson = function(el) {
|
||||
var editor = el.closest('.json-editor');
|
||||
var hidden = editor.querySelector('input[type=hidden]');
|
||||
if (editor.querySelector('input[placeholder="Значение"]')) {
|
||||
hidden.value = JSON.stringify(window.winCollectPairs(editor));
|
||||
} else {
|
||||
hidden.value = JSON.stringify(window.winCollectList(editor));
|
||||
}
|
||||
};
|
||||
|
||||
window.winAddJsonRow = function(btn) {
|
||||
var editor = btn.closest('.json-editor');
|
||||
btn.insertAdjacentHTML('beforebegin',
|
||||
editor.querySelector('input[placeholder="Значение"]')
|
||||
? '<div style="display:flex;gap:6px"><input placeholder="Название" oninput="winUpdateJson(this)" style="flex:1"><input placeholder="Значение" oninput="winUpdateJson(this)" style="flex:1"><button type="button" class="btn btn-danger btn-sm" onclick="this.parentElement.remove();var ed=this.closest(\'.json-editor\');ed.querySelector(\'input[type=hidden]\').value=JSON.stringify(winCollectPairs(ed))">×</button></div>'
|
||||
: '<div style="display:flex;gap:6px"><input oninput="winUpdateJson(this)" style="flex:1"><button type="button" class="btn btn-danger btn-sm" onclick="this.parentElement.remove();var ed=this.closest(\'.json-editor\');ed.querySelector(\'input[type=hidden]\').value=JSON.stringify(winCollectList(ed))">×</button></div>'
|
||||
);
|
||||
};
|
||||
|
||||
function buildForm(html, fields, rowData, isEdit, container) {
|
||||
fields.forEach(function(f) {
|
||||
var val = rowData && rowData[f.name] != null ? String(rowData[f.name]) : '';
|
||||
if (f.type === 'textarea') {
|
||||
html += '<div class="form-group2"><label>' + f.label + '</label><textarea name="' + f.name + '">' + esc(val) + '</textarea></div>';
|
||||
} else if (f.type === 'json') {
|
||||
html += '<div class="form-group2"><label>' + f.label + '</label>' + jsonEditor(f.name, val) + '</div>';
|
||||
} else if (f.type === 'number') {
|
||||
html += '<div class="form-group2"><label>' + f.label + '</label><input name="' + f.name + '" type="number" value="' + esc(val || (f['default'] != null ? String(f['default']) : '0')) + '"></div>';
|
||||
} else if (f.type === 'image') {
|
||||
var src = '';
|
||||
if (val) {
|
||||
if (val.startsWith('./')) src = val;
|
||||
else if (val.startsWith('http')) src = val;
|
||||
else if (val.startsWith('equip_') || val.startsWith('prt_') || val.startsWith('lic_') || val.startsWith('act_') || val.indexOf('/') !== -1) src = '/data/' + _uploadPrefix + '_images/' + val;
|
||||
else src = './img/' + val;
|
||||
}
|
||||
html += '<div class="form-group2"><label>' + f.label + '</label><input name="' + f.name + '" value="' + esc(val) + '">';
|
||||
if (src) html += '<div style="margin-top:6px"><img src="' + src + '" class="preview-img" onerror="this.style.display=\'none\'"></div>';
|
||||
html += '<input type="file" onchange="winUploadFile(this)" style="margin-top:6px"><div class="help">Загрузите файл или введите путь</div></div>';
|
||||
} else {
|
||||
html += '<div class="form-group2"><label>' + f.label + '</label><input name="' + f.name + '" value="' + esc(val) + '"></div>';
|
||||
}
|
||||
});
|
||||
html += '<div class="modal-actions"><button type="button" class="btn" onclick="closeModal()">Отмена</button><button type="submit" class="btn btn-primary">' + (isEdit ? 'Сохранить' : 'Создать') + '</button></div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
window.winUploadFile = function(input) {
|
||||
var file = input.files[0];
|
||||
if (!file) return;
|
||||
var fd = new FormData();
|
||||
fd.append('image', file);
|
||||
fetch(BASE + '/' + _uploadPrefix + '/upload', {
|
||||
method: 'POST', headers: { 'Authorization': 'Bearer ' + token }, body: fd
|
||||
}).then(function(r) { return r.json(); }).then(function(d) {
|
||||
if (d.path) {
|
||||
var nameInput = input.parentElement.querySelector('input:not([type=file])');
|
||||
if (nameInput) nameInput.value = d.path;
|
||||
toast('Файл загружен');
|
||||
} else toast('Ошибка загрузки', 'error');
|
||||
});
|
||||
};
|
||||
|
||||
function submitForm(formEl, id) {
|
||||
var fd = new FormData(formEl);
|
||||
var body = {};
|
||||
fd.forEach(function(v, k) { body[k] = v; });
|
||||
var url = id ? (BASE + '/' + _table + '/' + id) : (BASE + '/' + _table);
|
||||
api(url, { method: id ? 'PUT' : 'POST', body: JSON.stringify(body) }).then(function(r) {
|
||||
if (r.ok) { toast(id ? 'Сохранено' : 'Создано'); closeModal(); loadCurrentTab(); }
|
||||
else toast(r.data.error, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function createItem() {
|
||||
openModal('Создать', function(content) {
|
||||
content.innerHTML = '<form id="_itemForm">' + buildForm('', _fields, null, false, content) + '</form>';
|
||||
content.querySelector('#_itemForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault(); submitForm(this);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function editItem(id) {
|
||||
var row = _rows.find(function(r) { return r.id === id; });
|
||||
if (!row) return;
|
||||
openModal('Редактировать', function(content) {
|
||||
content.innerHTML = '<form id="_itemForm">' + buildForm('', _fields, row, true, content) + '</form>';
|
||||
content.querySelector('#_itemForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault(); submitForm(this, id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function deleteItem(id) {
|
||||
if (!confirm('Удалить?')) return;
|
||||
api(BASE + '/' + _table + '/' + id, { method: 'DELETE' }).then(function(r) {
|
||||
if (r.ok) { toast('Удалено'); loadCurrentTab(); }
|
||||
else toast(r.data.error, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function setupTableHandlers() {
|
||||
document.getElementById('mainContent').addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
var action = btn.dataset.action;
|
||||
var id = parseInt(btn.dataset.id);
|
||||
if (action === 'edit') editItem(id);
|
||||
else if (action === 'delete') deleteItem(id);
|
||||
});
|
||||
}
|
||||
|
||||
function loadCurrentTab() {
|
||||
_tabId++;
|
||||
currentTab = currentTab;
|
||||
var fn = window['tab_' + currentTab];
|
||||
if (fn) fn(_tabId);
|
||||
}
|
||||
|
||||
function renderCrud() {
|
||||
document.getElementById('mainContent').innerHTML = h() +
|
||||
'<button class="btn btn-primary" style="margin-bottom:16px" onclick="createItem()"><i class="fas fa-plus"></i> Добавить</button>' +
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function renderCrudAndLoad() {
|
||||
renderCrud();
|
||||
setupTableHandlers();
|
||||
}
|
||||
|
||||
window.createItem = createItem;
|
||||
window.editItem = editItem;
|
||||
window.deleteItem = deleteItem;
|
||||
window.closeModal = closeModal;
|
||||
window.doLogin = doLogin;
|
||||
window.logout = logout;
|
||||
|
||||
function configCrud(table, columns, fields, uploadPrefix) {
|
||||
_table = table;
|
||||
_columns = columns;
|
||||
_fields = fields;
|
||||
_uploadPrefix = uploadPrefix || table;
|
||||
var myTabId = _tabId;
|
||||
return api(BASE + '/' + table).then(function(r) {
|
||||
if (_tabId !== myTabId) return;
|
||||
_rows = r.ok ? (Array.isArray(r.data) ? r.data : []) : [];
|
||||
renderCrudAndLoad();
|
||||
});
|
||||
}
|
||||
|
||||
window.tab_services = function(tabId) {
|
||||
var myTabId = tabId;
|
||||
configCrud('services',
|
||||
[{field:'title',label:'Название'},{field:'description',label:'Описание',render:function(v){return String(v||'').substring(0,60)}},{field:'is_large',label:'Большая',render:function(v){return v==1?'<span class="badge badge-success">Да</span>':'<span class="badge badge-warning">Нет</span>'}},{field:'sort_order',label:'Порядок'},{field:'is_active',label:'Акт.',render:function(v){return v==1?'<span class="badge badge-success">Да</span>':'<span class="badge badge-warning">Нет</span>'}}],
|
||||
[{name:'svg_icon',label:'SVG иконка'},{name:'title',label:'Название'},{name:'description',label:'Описание',type:'textarea'},{name:'items',label:'Пункты списка',type:'json'},{name:'is_large',label:'Большая карточка',type:'number',default:'0'},{name:'sort_order',label:'Порядок',type:'number',default:'0'},{name:'is_active',label:'Активна',type:'number',default:'1'}]
|
||||
);
|
||||
};
|
||||
|
||||
window.tab_services._name = 'services';
|
||||
|
||||
window.tab_schemes = function(tabId) {
|
||||
var myTabId = tabId;
|
||||
configCrud('work_schemes',
|
||||
[{field:'title',label:'Название'},{field:'description',label:'Описание',render:function(v){return String(v||'').substring(0,60)}},{field:'sort_order',label:'Порядок'}],
|
||||
[{name:'svg_icon',label:'SVG иконка'},{name:'title',label:'Название'},{name:'description',label:'Описание',type:'textarea'},{name:'items',label:'Пункты списка',type:'json'},{name:'sort_order',label:'Порядок',type:'number',default:'0'},{name:'is_active',label:'Активна',type:'number',default:'1'}]
|
||||
);
|
||||
};
|
||||
|
||||
window.tab_process = function(tabId) {
|
||||
var myTabId = tabId;
|
||||
configCrud('process_steps',
|
||||
[{field:'step_number',label:'Шаг'},{field:'title',label:'Заголовок'},{field:'description',label:'Описание',render:function(v){return String(v||'').substring(0,60)}}],
|
||||
[{name:'step_number',label:'Номер шага',type:'number',default:'1'},{name:'title',label:'Заголовок'},{name:'description',label:'Описание',type:'textarea'},{name:'sort_order',label:'Порядок',type:'number',default:'0'}]
|
||||
);
|
||||
};
|
||||
|
||||
window.tab_equipment = function(tabId) {
|
||||
var myTabId = tabId;
|
||||
_table = 'equipment';
|
||||
_columns = [
|
||||
{field:'name',label:'Название'},
|
||||
{field:'brand',label:'Бренд'},
|
||||
{field:'qty',label:'Кол-во'},
|
||||
{field:'age_badge',label:'Возраст'},
|
||||
{field:'is_active',label:'Акт.',render:function(v){return v==1?'<span class="badge badge-success">Да</span>':'<span class="badge badge-warning">Нет</span>'}}
|
||||
];
|
||||
_fields = [
|
||||
{name:'name',label:'Название'},{name:'brand',label:'Бренд'},
|
||||
{name:'image_path',label:'Главное изображение',type:'image'},
|
||||
{name:'qty',label:'Количество'},{name:'specs',label:'Характеристики',type:'json'},
|
||||
{name:'age_badge',label:'Бейдж возраста'},{name:'sort_order',label:'Порядок',type:'number',default:'0'},
|
||||
{name:'is_active',label:'Активна',type:'number',default:'1'}
|
||||
];
|
||||
_uploadPrefix = 'equipment';
|
||||
|
||||
api(BASE + '/equipment').then(function(r) {
|
||||
if (_tabId !== myTabId) return;
|
||||
_rows = r.ok ? (Array.isArray(r.data) ? r.data : []) : [];
|
||||
renderCrudAndLoad();
|
||||
});
|
||||
|
||||
var origEdit = editItem;
|
||||
editItem = function(id) {
|
||||
var row = _rows.find(function(r) { return r.id === id; });
|
||||
if (!row) return;
|
||||
openModal('Редактировать', function(content) {
|
||||
content.innerHTML = '<form id="_itemForm">' + buildForm('', _fields, row, true, content) + `
|
||||
<div style="border-top:1px solid var(--border);padding-top:16px;margin-top:8px">
|
||||
<h4 style="margin-bottom:12px;color:var(--orange-dark)">Галерея изображений</h4>
|
||||
<div id="galleryList" style="display:flex;flex-wrap:wrap;gap:10px;margin-bottom:12px"></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group2">
|
||||
<label>Загрузить фото</label>
|
||||
<input type="file" id="galUpload" multiple accept=".jpg,.jpeg,.png,.webp" style="padding:8px">
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-primary" onclick="winUploadGallery(${id})" style="margin-top:6px">
|
||||
<i class="fas fa-upload"></i> Загрузить выбранные
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
content.querySelector('#_itemForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault(); submitForm(this, id);
|
||||
});
|
||||
loadGallery(id);
|
||||
});
|
||||
};
|
||||
|
||||
window.winUploadGallery = async function(eqId) {
|
||||
var inp = document.getElementById('galUpload');
|
||||
if (!inp.files.length) return;
|
||||
var fd = new FormData();
|
||||
for (var i = 0; i < inp.files.length; i++) fd.append('images', inp.files[i]);
|
||||
var r = await fetch(BASE + '/equipment/gallery/upload', {
|
||||
method:'POST', headers:{'Authorization':'Bearer '+token}, body:fd
|
||||
}).then(function(x) { return x.json(); });
|
||||
if (Array.isArray(r)) {
|
||||
for (var j = 0; j < r.length; j++) {
|
||||
await api(BASE+'/equipment/'+eqId+'/gallery', {
|
||||
method:'POST',
|
||||
body:JSON.stringify({image_path:r[j].path, sort_order:j})
|
||||
});
|
||||
}
|
||||
toast('Загружено: ' + r.length + ' фото');
|
||||
loadGallery(eqId);
|
||||
}
|
||||
};
|
||||
|
||||
window.loadGallery = async function(eqId) {
|
||||
var r = await api(BASE+'/equipment/'+eqId+'/gallery');
|
||||
var items = r.ok ? (Array.isArray(r.data) ? r.data : []) : [];
|
||||
var el = document.getElementById('galleryList');
|
||||
if (!el) return;
|
||||
var h = '';
|
||||
items.forEach(function(img) {
|
||||
var src = img.image_path;
|
||||
if (!src.startsWith('http') && !src.startsWith('./')) {
|
||||
src = (src.startsWith('equip_') || src.indexOf('/') !== -1) ? ('/data/equipment_images/' + src) : ('./img/' + src);
|
||||
}
|
||||
h += '<div style="position:relative;width:100px;height:80px;border:1px solid var(--border);border-radius:6px;overflow:hidden">' +
|
||||
'<img src="' + src + '" style="width:100%;height:100%;object-fit:cover" onerror="this.src=\'data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 80%22><text y=%2250%25%22 x=%2250%25%22 text-anchor=%22middle%22>?</text></svg>\'">' +
|
||||
'<button class="btn-icon delete" style="position:absolute;top:2px;right:2px;background:var(--danger);color:#fff;border-radius:3px;padding:2px 5px;font-size:11px" onclick="event.stopPropagation();winDelGallery(' + img.id + ')">×</button>' +
|
||||
'</div>';
|
||||
});
|
||||
el.innerHTML = h || '<span class="text-muted">Нет фото галереи</span>';
|
||||
};
|
||||
|
||||
window.winDelGallery = async function(imgId) {
|
||||
if (!confirm('Удалить фото?')) return;
|
||||
var r = await api(BASE+'/equipment/gallery/'+imgId, {method:'DELETE'});
|
||||
if (r.ok) { toast('Удалено'); loadGallery(_rows.find(function(x){return true}).id); }
|
||||
};
|
||||
};
|
||||
|
||||
window.tab_partners = function(tabId) {
|
||||
var myTabId = tabId;
|
||||
configCrud('partners',
|
||||
[{field:'name',label:'Название'},{field:'sort_order',label:'Порядок'},{field:'is_active',label:'Акт.',render:function(v){return v==1?'<span class="badge badge-success">Да</span>':'<span class="badge badge-warning">Нет</span>'}}],
|
||||
[{name:'name',label:'Название'},{name:'logo_path',label:'Логотип',type:'image'},{name:'sort_order',label:'Порядок',type:'number',default:'0'},{name:'is_active',label:'Активен',type:'number',default:'1'}],
|
||||
'partners'
|
||||
);
|
||||
};
|
||||
|
||||
window.tab_hero = function(tabId) {
|
||||
var myTabId = tabId;
|
||||
Promise.all([
|
||||
api(BASE + '/hero-settings'),
|
||||
api(BASE + '/hero_stats')
|
||||
]).then(function(results) {
|
||||
if (_tabId !== myTabId) return;
|
||||
var settings = results[0].ok ? results[0].data : {};
|
||||
var stats = results[1].ok ? (Array.isArray(results[1].data) ? results[1].data : []) : [];
|
||||
|
||||
_rows = stats;
|
||||
_columns = [{field:'id',label:'ID'},{field:'label',label:'Название'},{field:'value',label:'Значение'},{field:'is_dynamic',label:'Динамич.',render:function(v){return v==1?'<span class="badge badge-success">Да</span>':'<span class="badge badge-warning">Нет</span>'}},{field:'sort_order',label:'Порядок'}];
|
||||
_fields = [{name:'label',label:'Название'},{name:'value',label:'Значение'},{name:'is_dynamic',label:'Динамическое ({years})',type:'number',default:'0'},{name:'sort_order',label:'Порядок',type:'number',default:'0'}];
|
||||
_table = 'hero_stats';
|
||||
_uploadPrefix = 'hero';
|
||||
|
||||
var keys = ['hero_bg','badge_text','h1_text','subtitle','phone_800','phone_ekb','email','address','button1_text','button1_link','button2_text','button2_link'];
|
||||
|
||||
document.getElementById('mainContent').innerHTML = h() +
|
||||
'<button class="btn btn-primary" style="margin-bottom:8px" onclick="winEditHeroSettings()"><i class="fas fa-edit"></i> Настройки Hero</button>' +
|
||||
'<button class="btn btn-primary" style="margin-left:8px;margin-bottom:8px" onclick="createItem()"><i class="fas fa-plus"></i> Добавить статистику</button>' +
|
||||
'<div class="settings-grid" style="margin-bottom:16px"><div class="settings-card"><h3>Текущие настройки</h3>' +
|
||||
keys.map(function(k) { return '<p style="margin-bottom:3px"><strong>' + k + ':</strong> <span class="text-muted">' + esc(String(settings[k]||'').substring(0,60)) + '</span></p>'; }).join('') +
|
||||
'</div></div>' + renderTable();
|
||||
setupTableHandlers();
|
||||
|
||||
window._heroSettings = settings;
|
||||
window.winEditHeroSettings = function() {
|
||||
var s = window._heroSettings;
|
||||
openModal('Hero-настройки', function(content) {
|
||||
var html = '<form id="_heroForm">';
|
||||
keys.forEach(function(k) {
|
||||
html += '<div class="form-group2"><label>' + k + '</label><input name="' + k + '" value="' + esc(s[k]||'') + '"></div>';
|
||||
});
|
||||
html += '<div class="modal-actions"><button type="button" class="btn" onclick="closeModal()">Отмена</button><button type="submit" class="btn btn-primary">Сохранить</button></div></form>';
|
||||
content.insertAdjacentHTML('beforeend', html);
|
||||
content.querySelector('#_heroForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
var body = {};
|
||||
new FormData(this).forEach(function(v,k){body[k]=v;});
|
||||
api(BASE+'/hero-settings', { method:'PUT', body:JSON.stringify(body) }).then(function(r) {
|
||||
if(r.ok){toast('Сохранено');closeModal();loadCurrentTab();}else toast(r.data.error,'error');
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
window.tab_about = function(tabId) {
|
||||
var myTabId = tabId;
|
||||
Promise.all([
|
||||
api(BASE + '/about-settings'),
|
||||
api(BASE + '/about_features')
|
||||
]).then(function(results) {
|
||||
if (_tabId !== myTabId) return;
|
||||
var settings = results[0].ok ? results[0].data : {};
|
||||
var features = results[1].ok ? (Array.isArray(results[1].data) ? results[1].data : []) : [];
|
||||
|
||||
_rows = features;
|
||||
_columns = [{field:'title',label:'Заголовок'},{field:'description',label:'Описание'},{field:'sort_order',label:'Порядок'}];
|
||||
_fields = [{name:'svg_icon',label:'SVG иконка'},{name:'title',label:'Заголовок'},{name:'description',label:'Описание'},{name:'sort_order',label:'Порядок',type:'number',default:'0'}];
|
||||
_table = 'about_features';
|
||||
_uploadPrefix = 'about';
|
||||
|
||||
var aboutKeys = ['section_subtitle','main_heading','about_image','paragraph_1','paragraph_2','paragraph_3'];
|
||||
|
||||
document.getElementById('mainContent').innerHTML = h() +
|
||||
'<button class="btn btn-primary" style="margin-bottom:8px" onclick="winEditAboutSettings()"><i class="fas fa-edit"></i> Настройки раздела</button>' +
|
||||
'<button class="btn btn-primary" style="margin-left:8px;margin-bottom:8px" onclick="createItem()"><i class="fas fa-plus"></i> Добавить преимущество</button>' +
|
||||
renderTable();
|
||||
setupTableHandlers();
|
||||
|
||||
window._aboutSettings = settings;
|
||||
window.winEditAboutSettings = function() {
|
||||
var s = window._aboutSettings;
|
||||
openModal('Настройки "О компании"', function(content) {
|
||||
var html = '<form id="_aboutForm">';
|
||||
aboutKeys.forEach(function(k) {
|
||||
html += '<div class="form-group2"><label>' + k + '</label><textarea name="' + k + '">' + esc(s[k]||'') + '</textarea></div>';
|
||||
});
|
||||
html += '<div class="modal-actions"><button type="button" class="btn" onclick="closeModal()">Отмена</button><button type="submit" class="btn btn-primary">Сохранить</button></div></form>';
|
||||
content.insertAdjacentHTML('beforeend', html);
|
||||
content.querySelector('#_aboutForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
var body = {};
|
||||
new FormData(this).forEach(function(v,k){body[k]=v;});
|
||||
api(BASE+'/about-settings', { method:'PUT', body:JSON.stringify(body) }).then(function(r) {
|
||||
if(r.ok){toast('Сохранено');closeModal();loadCurrentTab();}else toast(r.data.error,'error');
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
window.tab_geography = function(tabId) {
|
||||
var myTabId = tabId;
|
||||
Promise.all([
|
||||
api(BASE + '/geo-settings'),
|
||||
api(BASE + '/geo_regions')
|
||||
]).then(function(results) {
|
||||
if (_tabId !== myTabId) return;
|
||||
var settings = results[0].ok ? results[0].data : {};
|
||||
var regions = results[1].ok ? (Array.isArray(results[1].data) ? results[1].data : []) : [];
|
||||
|
||||
_rows = regions;
|
||||
_columns = [{field:'name',label:'Регион'},{field:'sort_order',label:'Порядок'}];
|
||||
_fields = [{name:'name',label:'Название'},{name:'sort_order',label:'Порядок',type:'number',default:'0'}];
|
||||
_table = 'geo_regions';
|
||||
_uploadPrefix = 'geography';
|
||||
|
||||
var geoKeys = ['section_subtitle','difficult_title','difficult_desc','map_image'];
|
||||
|
||||
document.getElementById('mainContent').innerHTML = h() +
|
||||
'<button class="btn btn-primary" style="margin-bottom:8px" onclick="winEditGeoSettings()"><i class="fas fa-edit"></i> Гео-настройки</button>' +
|
||||
'<button class="btn btn-primary" style="margin-left:8px;margin-bottom:8px" onclick="createItem()"><i class="fas fa-plus"></i> Добавить регион</button>' +
|
||||
renderTable();
|
||||
setupTableHandlers();
|
||||
|
||||
window._geoSettings = settings;
|
||||
window.winEditGeoSettings = function() {
|
||||
var s = window._geoSettings;
|
||||
openModal('Гео-настройки', function(content) {
|
||||
var html = '<form id="_geoForm">';
|
||||
geoKeys.forEach(function(k) {
|
||||
html += '<div class="form-group2"><label>' + k + '</label><textarea name="' + k + '">' + esc(s[k]||'') + '</textarea></div>';
|
||||
});
|
||||
html += '<div class="modal-actions"><button type="button" class="btn" onclick="closeModal()">Отмена</button><button type="submit" class="btn btn-primary">Сохранить</button></div></form>';
|
||||
content.insertAdjacentHTML('beforeend', html);
|
||||
content.querySelector('#_geoForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
var body = {};
|
||||
new FormData(this).forEach(function(v,k){body[k]=v;});
|
||||
api(BASE+'/geo-settings', { method:'PUT', body:JSON.stringify(body) }).then(function(r) {
|
||||
if(r.ok){toast('Сохранено');closeModal();loadCurrentTab();}else toast(r.data.error,'error');
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
window.tab_licenses = function(tabId) {
|
||||
var myTabId = tabId;
|
||||
Promise.all([
|
||||
api(BASE + '/licenses'),
|
||||
api(BASE + '/license_gallery')
|
||||
]).then(function(results) {
|
||||
if (_tabId !== myTabId) return;
|
||||
var cards = results[0].ok ? (Array.isArray(results[0].data) ? results[0].data : []) : [];
|
||||
var gallery = results[1].ok ? (Array.isArray(results[1].data) ? results[1].data : []) : [];
|
||||
|
||||
var html = h();
|
||||
html += '<div style="margin-bottom:8px"><strong style="color:var(--orange)">Лицензии (карточки)</strong></div>';
|
||||
|
||||
_rows = cards;
|
||||
_columns = [{field:'title',label:'Название'},{field:'description',label:'Описание',render:function(v){return String(v||'').substring(0,50)}}];
|
||||
_fields = [{name:'svg_icon',label:'SVG иконка'},{name:'title',label:'Название'},{name:'description',label:'Описание',type:'textarea'},{name:'sort_order',label:'Порядок',type:'number',default:'0'}];
|
||||
_table = 'licenses';
|
||||
_uploadPrefix = 'licenses';
|
||||
|
||||
html += '<button class="btn btn-primary btn-sm" style="margin-bottom:12px" onclick="createItem()"><i class="fas fa-plus"></i> Добавить</button>';
|
||||
html += renderTable();
|
||||
html += '<div style="margin-top:20px"><strong style="color:var(--orange)">Галерея лицензий</strong></div>';
|
||||
html += '<button class="btn btn-primary btn-sm" style="margin-bottom:12px" onclick="winSwLicGal()"><i class="fas fa-plus"></i> Добавить</button>';
|
||||
html += renderGalleryTable(gallery);
|
||||
|
||||
document.getElementById('mainContent').innerHTML = html;
|
||||
setupTableHandlers();
|
||||
|
||||
window.winSwLicGal = function() {
|
||||
_rows = gallery;
|
||||
_columns = [{field:'title',label:'Название'},{field:'description',label:'Описание'}];
|
||||
_fields = [{name:'title',label:'Название'},{name:'description',label:'Описание',type:'textarea'},{name:'image_path',label:'Изображение',type:'image'},{name:'sort_order',label:'Порядок',type:'number',default:'0'}];
|
||||
_table = 'license_gallery';
|
||||
_uploadPrefix = 'licenses';
|
||||
createItem();
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
function renderGalleryTable(rows) {
|
||||
if (!rows || rows.length === 0) return '<p class="text-muted">Нет данных</p>';
|
||||
var html = '<div class="table-wrap"><table><thead><tr><th>Название</th><th>Описание</th><th>Действия</th></tr></thead><tbody>';
|
||||
rows.forEach(function(row) {
|
||||
html += '<tr><td>' + esc(row.title) + '</td><td>' + esc(String(row.description||'').substring(0,50)) + '</td><td class="actions">';
|
||||
html += '<button class="btn-icon edit" data-action="edit" data-id="' + row.id + '"><i class="fas fa-edit"></i></button>';
|
||||
html += '<button class="btn-icon delete" data-action="delete" data-id="' + row.id + '"><i class="fas fa-trash"></i></button>';
|
||||
html += '</td></tr>';
|
||||
});
|
||||
html += '</tbody></table></div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
window.tab_activities = function(tabId) {
|
||||
var myTabId = tabId;
|
||||
Promise.all([
|
||||
api(BASE + '/public_activities'),
|
||||
api(BASE + '/activity_gallery')
|
||||
]).then(function(results) {
|
||||
if (_tabId !== myTabId) return;
|
||||
var cards = results[0].ok ? (Array.isArray(results[0].data) ? results[0].data : []) : [];
|
||||
var gallery = results[1].ok ? (Array.isArray(results[1].data) ? results[1].data : []) : [];
|
||||
|
||||
_rows = cards;
|
||||
_columns = [{field:'title',label:'Название'},{field:'description',label:'Описание',render:function(v){return String(v||'').substring(0,50)}}];
|
||||
_fields = [{name:'svg_icon',label:'SVG иконка'},{name:'title',label:'Название'},{name:'description',label:'Описание',type:'textarea'},{name:'sort_order',label:'Порядок',type:'number',default:'0'}];
|
||||
_table = 'public_activities';
|
||||
_uploadPrefix = 'activities';
|
||||
|
||||
document.getElementById('mainContent').innerHTML = h() +
|
||||
'<div style="margin-bottom:8px"><strong style="color:var(--orange)">Карточки деятельности</strong></div>' +
|
||||
'<button class="btn btn-primary btn-sm" style="margin-bottom:12px" onclick="createItem()" id="btnActCards"><i class="fas fa-plus"></i> Добавить</button>' +
|
||||
renderTable() +
|
||||
'<div style="margin-top:20px"><strong style="color:var(--orange)">Благодарственные письма</strong></div>' +
|
||||
'<button class="btn btn-primary btn-sm" style="margin-bottom:12px" onclick="winSwActGal()"><i class="fas fa-plus"></i> Добавить</button>' +
|
||||
renderGalleryTable(gallery);
|
||||
setupTableHandlers();
|
||||
|
||||
window.winSwActGal = function() {
|
||||
_rows = gallery;
|
||||
_columns = [{field:'title',label:'Название'},{field:'description',label:'Описание'}];
|
||||
_fields = [{name:'title',label:'Название'},{name:'description',label:'Описание',type:'textarea'},{name:'image_path',label:'Изображение',type:'image'},{name:'sort_order',label:'Порядок',type:'number',default:'0'}];
|
||||
_table = 'activity_gallery';
|
||||
_uploadPrefix = 'activities';
|
||||
createItem();
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
window.tab_settings = function(tabId) {
|
||||
var myTabId = tabId;
|
||||
document.getElementById('mainContent').innerHTML = h() + '<div style="text-align:center;padding:30px"><i class="fas fa-spinner fa-spin"></i></div>';
|
||||
api(BASE + '/settings').then(function(r) {
|
||||
var s = r.ok ? r.data : {};
|
||||
|
||||
var contactKeys = ['contact_phone_800','contact_phone_ekb','contact_email','contact_address','contact_work_hours','contact_map_image','footer_text'];
|
||||
var formKeys = ['form_title','form_subtitle','section_services_subtitle','section_schemes_subtitle','section_process_subtitle','section_equipment_subtitle','section_licenses_subtitle','section_activity_subtitle','section_partners_subtitle','section_contacts_subtitle'];
|
||||
|
||||
document.getElementById('mainContent').innerHTML = h() +
|
||||
'<div class="settings-grid">' +
|
||||
'<div class="settings-card"><h3>Контакты</h3>' +
|
||||
contactKeys.map(function(k) { return '<p style="margin-bottom:4px"><strong>' + k + ':</strong> <span class="text-muted">' + esc(String(s[k]||'').substring(0,80)) + '</span></p>'; }).join('') +
|
||||
'<button class="btn btn-sm btn-primary" style="margin-top:8px" onclick="winEditSettings(\'contacts\')"><i class="fas fa-edit"></i> Редактировать</button>' +
|
||||
'</div>' +
|
||||
'<div class="settings-card"><h3>Форма и подзаголовки</h3>' +
|
||||
formKeys.map(function(k) { return '<p style="margin-bottom:4px"><strong>' + k.replace('section_','').substring(0,20) + ':</strong> <span class="text-muted">' + esc(String(s[k]||'').substring(0,60)) + '</span></p>'; }).join('') +
|
||||
'<button class="btn btn-sm btn-primary" style="margin-top:8px" onclick="winEditSettings(\'form\')"><i class="fas fa-edit"></i> Редактировать</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
window._settings = s;
|
||||
window.winEditSettings = function(mode) {
|
||||
var s = window._settings;
|
||||
var keys = mode === 'contacts' ? contactKeys : formKeys;
|
||||
openModal('Редактировать', function(content) {
|
||||
var html = '<form id="_setForm">';
|
||||
keys.forEach(function(k) {
|
||||
html += '<div class="form-group2"><label>' + k + '</label><input name="' + k + '" value="' + esc(s[k]||'') + '"></div>';
|
||||
});
|
||||
html += '<div class="modal-actions"><button type="button" class="btn" onclick="closeModal()">Отмена</button><button type="submit" class="btn btn-primary">Сохранить</button></div></form>';
|
||||
content.insertAdjacentHTML('beforeend', html);
|
||||
content.querySelector('#_setForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
var body = {};
|
||||
new FormData(this).forEach(function(v,k){body[k]=v;});
|
||||
api(BASE+'/settings', { method:'PUT', body:JSON.stringify(body) }).then(function(r) {
|
||||
if(r.ok){toast('Сохранено');closeModal();loadCurrentTab();}else toast(r.data.error,'error');
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
window.tab_leads = function(tabId) {
|
||||
var myTabId = tabId;
|
||||
var page = 1;
|
||||
function renderLeads(data) {
|
||||
_rows = data.data || [];
|
||||
_columns = [{field:'id',label:'ID'},{field:'name',label:'Имя'},{field:'phone',label:'Телефон'},{field:'message',label:'Сообщение',render:function(v){return String(v||'').substring(0,60)}},{field:'created_at',label:'Дата'}];
|
||||
_table = 'leads';
|
||||
_fields = [];
|
||||
_uploadPrefix = 'leads';
|
||||
|
||||
document.getElementById('mainContent').innerHTML = h() +
|
||||
'<div class="search-bar"><input id="leadSearch" placeholder="Поиск по заявкам..." oninput="winSearchLeads()"></div>' +
|
||||
((_rows && _rows.length > 0) ? renderTable() : '<p class="text-muted">Нет заявок</p>') +
|
||||
'<div class="pagination"><button ' + (data.page<=1?'disabled':'') + ' onclick="winGoPage(' + (data.page-1) + ')">←</button><span>' + data.page + ' / ' + data.totalPages + '</span><button ' + (data.page>=data.totalPages?'disabled':'') + ' onclick="winGoPage(' + (data.page+1) + ')">→</button></div>';
|
||||
setupTableHandlers();
|
||||
}
|
||||
|
||||
window.winSearchLeads = function() {
|
||||
page = 1;
|
||||
var q = document.getElementById('leadSearch').value;
|
||||
api(BASE + '/leads?page=1&search=' + encodeURIComponent(q)).then(function(r) {
|
||||
if (r.ok) renderLeads(r.data);
|
||||
});
|
||||
};
|
||||
window.winGoPage = function(p) {
|
||||
page = p;
|
||||
var q = document.getElementById('leadSearch') ? document.getElementById('leadSearch').value : '';
|
||||
api(BASE + '/leads?page=' + p + '&search=' + encodeURIComponent(q)).then(function(r) {
|
||||
if (r.ok) renderLeads(r.data);
|
||||
});
|
||||
};
|
||||
|
||||
api(BASE + '/leads?page=1').then(function(r) {
|
||||
renderLeads(r.ok ? r.data : { data: [], total: 0, page: 1, totalPages: 1 });
|
||||
});
|
||||
};
|
||||
|
||||
window.tab_users = function(tabId) {
|
||||
var myTabId = tabId;
|
||||
document.getElementById('mainContent').innerHTML = h() + '<div style="text-align:center;padding:30px"><i class="fas fa-spinner fa-spin"></i></div>';
|
||||
api(BASE + '/users').then(function(r) {
|
||||
var rows = r.ok ? (Array.isArray(r.data) ? r.data : []) : [];
|
||||
|
||||
_rows = rows;
|
||||
_columns = [{field:'id',label:'ID'},{field:'login',label:'Логин'},{field:'full_name',label:'ФИО'},{field:'role',label:'Роль',render:function(v){return '<span class="badge badge-success">'+v+'</span>'}},{field:'created_at',label:'Создан'}];
|
||||
_table = 'users';
|
||||
_uploadPrefix = 'users';
|
||||
|
||||
document.getElementById('mainContent').innerHTML = h() +
|
||||
'<button class="btn btn-primary" style="margin-bottom:16px" onclick="winCreateUser()"><i class="fas fa-plus"></i> Добавить</button>' +
|
||||
renderTable();
|
||||
setupTableHandlers();
|
||||
|
||||
window.winCreateUser = function() {
|
||||
openModal('Создать пользователя', function(content) {
|
||||
var html = '<form id="_userForm"><div class="form-group2"><label>Логин</label><input name="login" required></div><div class="form-group2"><label>Пароль</label><input name="password" type="password" required></div><div class="form-group2"><label>ФИО</label><input name="full_name"></div><div class="form-group2"><label>Роль</label><select name="role"><option value="admin">admin</option></select></div><div class="modal-actions"><button type="button" class="btn" onclick="closeModal()">Отмена</button><button type="submit" class="btn btn-primary">Создать</button></div></form>';
|
||||
content.insertAdjacentHTML('beforeend', html);
|
||||
content.querySelector('#_userForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
var body = {};
|
||||
new FormData(this).forEach(function(v,k){body[k]=v;});
|
||||
api(BASE+'/users', { method:'POST', body:JSON.stringify(body) }).then(function(r) {
|
||||
if(r.ok){toast('Создан');closeModal();loadCurrentTab();}else toast(r.data.error,'error');
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
var origEdit = editItem;
|
||||
editItem = function(id) {
|
||||
var row = _rows.find(function(r) { return r.id === id; });
|
||||
if (!row) return;
|
||||
openModal('Редактировать', function(content) {
|
||||
var html = '<form id="_userEdit">';
|
||||
html += '<div class="form-group2"><label>Логин</label><input name="login" value="' + esc(row.login) + '" required></div>';
|
||||
html += '<div class="form-group2"><label>Новый пароль (пусто = не менять)</label><input name="password" type="password"></div>';
|
||||
html += '<div class="form-group2"><label>ФИО</label><input name="full_name" value="' + esc(row.full_name||'') + '"></div>';
|
||||
html += '<div class="form-group2"><label>Роль</label><select name="role"><option value="admin" ' + (row.role==='admin'?'selected':'') + '>admin</option></select></div>';
|
||||
html += '<div class="modal-actions"><button type="button" class="btn" onclick="closeModal()">Отмена</button><button type="submit" class="btn btn-primary">Сохранить</button></div></form>';
|
||||
content.insertAdjacentHTML('beforeend', html);
|
||||
content.querySelector('#_userEdit').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
var body = {};
|
||||
new FormData(this).forEach(function(v,k) { if(v) body[k]=v; });
|
||||
api(BASE+'/users/'+id, { method:'PUT', body:JSON.stringify(body) }).then(function(r) {
|
||||
if(r.ok){toast('Сохранено');closeModal();loadCurrentTab();}else toast(r.data.error,'error');
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
window.tab_dashboard = function(tabId) {
|
||||
var myTabId = tabId;
|
||||
Promise.all([
|
||||
api(BASE + '/leads?page=1'),
|
||||
api(BASE + '/users')
|
||||
]).then(function(results) {
|
||||
if (_tabId !== myTabId) return;
|
||||
var totalLeads = results[0].ok ? (results[0].data.total || 0) : 0;
|
||||
var userCount = results[1].ok ? (Array.isArray(results[1].data) ? results[1].data.length : 0) : 0;
|
||||
document.getElementById('mainContent').innerHTML = h() +
|
||||
'<div class="stats-grid">' +
|
||||
'<div class="stat-card"><i class="fas fa-envelope"></i><div class="num">' + totalLeads + '</div><div class="lbl">Всего заявок</div></div>' +
|
||||
'<div class="stat-card"><i class="fas fa-users"></i><div class="num">' + userCount + '</div><div class="lbl">Пользователей</div></div>' +
|
||||
'<div class="stat-card"><i class="fas fa-database"></i><div class="num">14</div><div class="lbl">Разделов сайта</div></div>' +
|
||||
'</div>' +
|
||||
'<p class="text-muted">Выберите раздел в боковом меню для редактирования.</p>';
|
||||
});
|
||||
};
|
||||
|
||||
function enterPanel() {
|
||||
document.getElementById('login-page').style.display = 'none';
|
||||
document.getElementById('admin-panel').style.display = 'flex';
|
||||
document.querySelectorAll('.sidebar-nav a[data-tab]').forEach(function(a) {
|
||||
a.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
document.querySelectorAll('.sidebar-nav a[data-tab]').forEach(function(x) { x.classList.remove('active'); });
|
||||
this.classList.add('active');
|
||||
currentTab = this.dataset.tab;
|
||||
loadCurrentTab();
|
||||
});
|
||||
});
|
||||
document.getElementById('mainContent').innerHTML = '<div style="text-align:center;padding:60px"><i class="fas fa-spinner fa-spin" style="font-size:28px;color:var(--orange)"></i></div>';
|
||||
loadCurrentTab();
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('adminToken'); token = null; user = null;
|
||||
document.getElementById('login-page').style.display = 'flex';
|
||||
document.getElementById('admin-panel').style.display = 'none';
|
||||
_rows = []; _table = ''; _columns = []; _fields = [];
|
||||
}
|
||||
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1286
public/index.html
1286
public/index.html
File diff suppressed because it is too large
Load Diff
163
server.js
163
server.js
@@ -18,118 +18,102 @@ const supportedFormats = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff'];
|
||||
|
||||
async function convertImagesToWebp() {
|
||||
if (!fs.existsSync(imgDir)) {
|
||||
console.log('📁 Папка img не найдена, пропускаем конвертацию');
|
||||
console.log('Папка img не найдена, пропускаем конвертацию');
|
||||
return;
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(imgDir);
|
||||
const toConvert = files.filter(f => {
|
||||
const ext = path.extname(f).toLowerCase();
|
||||
return supportedFormats.includes(ext) && !f.endsWith('.webp');
|
||||
});
|
||||
|
||||
if (toConvert.length === 0) {
|
||||
console.log('✅ Все изображения уже в формате WebP');
|
||||
console.log('Все изображения уже в формате WebP');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`🔄 Конвертация ${toConvert.length} изображений в WebP...`);
|
||||
|
||||
console.log(`Конвертация ${toConvert.length} изображений в WebP...`);
|
||||
for (const file of toConvert) {
|
||||
const inputPath = path.join(imgDir, file);
|
||||
const outputPath = path.join(imgDir, path.basename(file, path.extname(file)) + '.webp');
|
||||
|
||||
try {
|
||||
await sharp(inputPath)
|
||||
.webp({ quality: 85 })
|
||||
.toFile(outputPath);
|
||||
await sharp(inputPath).webp({ quality: 85 }).toFile(outputPath);
|
||||
fs.unlinkSync(inputPath);
|
||||
console.log(` ✅ ${file} → ${path.basename(outputPath)}`);
|
||||
console.log(` ${file} -> ${path.basename(outputPath)}`);
|
||||
} catch (err) {
|
||||
console.error(` ❌ Ошибка конвертации ${file}:`, err.message);
|
||||
console.error(` Ошибка конвертации ${file}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ Конвертация завершена');
|
||||
console.log('Конвертация завершена');
|
||||
}
|
||||
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
// Ensure data directory and database
|
||||
const dataDir = path.join(__dirname, 'data');
|
||||
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir);
|
||||
|
||||
[
|
||||
'equipment_images', 'partner_images', 'license_images',
|
||||
'activity_images', 'hero_images'
|
||||
].forEach(dir => {
|
||||
const p = path.join(dataDir, dir);
|
||||
if (!fs.existsSync(p)) fs.mkdirSync(p);
|
||||
app.use('/data/' + dir, express.static(p));
|
||||
});
|
||||
|
||||
const dbPath = path.join(dataDir, 'bookings.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS leads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
phone TEXT NOT NULL,
|
||||
message TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`);
|
||||
const dbModule = require('./modules/database');
|
||||
const crudModule = require('./modules/database/crud');
|
||||
const authModule = require('./modules/auth');
|
||||
const heroModule = require('./modules/hero');
|
||||
const aboutModule = require('./modules/about');
|
||||
const servicesModule = require('./modules/services');
|
||||
const workSchemesModule = require('./modules/workSchemes');
|
||||
const processStepsModule = require('./modules/processSteps');
|
||||
const equipmentModule = require('./modules/equipment');
|
||||
const geographyModule = require('./modules/geography');
|
||||
const licensesModule = require('./modules/licenses');
|
||||
const activitiesModule = require('./modules/activities');
|
||||
const partnersModule = require('./modules/partners');
|
||||
const settingsModule = require('./modules/settings');
|
||||
const usersModule = require('./modules/users');
|
||||
const leadsModule = require('./modules/leads');
|
||||
const siteDataModule = require('./modules/siteData');
|
||||
|
||||
// API: POST /api/leads – сохранить новую заявку (публичный)
|
||||
app.post('/api/leads', (req, res) => {
|
||||
const { name, phone, message } = req.body;
|
||||
if (!name || !phone) {
|
||||
return res.status(400).json({ error: 'Missing required fields: name, phone' });
|
||||
}
|
||||
const stmt = db.prepare(`INSERT INTO leads (name, phone, message) VALUES (?, ?, ?)`);
|
||||
stmt.run(name.trim(), phone.trim(), (message || '').trim(), function(err) {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
console.log(`✅ Новая заявка #${this.lastID}: ${name} | ${phone}`);
|
||||
res.status(201).json({ id: this.lastID, message: 'Lead saved' });
|
||||
});
|
||||
stmt.finalize();
|
||||
[
|
||||
crudModule, authModule, heroModule, aboutModule,
|
||||
servicesModule, workSchemesModule, processStepsModule,
|
||||
equipmentModule, geographyModule, licensesModule,
|
||||
activitiesModule, partnersModule, settingsModule,
|
||||
usersModule, leadsModule, siteDataModule
|
||||
].forEach(m => m.init(db));
|
||||
|
||||
const auth = {
|
||||
authenticateToken: authModule.authenticateToken,
|
||||
requireAdmin: authModule.requireAdmin
|
||||
};
|
||||
|
||||
authModule.setupRoutes(app);
|
||||
heroModule.setupRoutes(app, auth);
|
||||
aboutModule.setupRoutes(app, auth);
|
||||
servicesModule.setupRoutes(app, auth);
|
||||
workSchemesModule.setupRoutes(app, auth);
|
||||
processStepsModule.setupRoutes(app, auth);
|
||||
equipmentModule.setupRoutes(app, auth);
|
||||
geographyModule.setupRoutes(app, auth);
|
||||
licensesModule.setupRoutes(app, auth);
|
||||
activitiesModule.setupRoutes(app, auth);
|
||||
partnersModule.setupRoutes(app, auth);
|
||||
settingsModule.setupRoutes(app, auth);
|
||||
usersModule.setupRoutes(app, auth);
|
||||
leadsModule.setupRoutes(app, auth);
|
||||
siteDataModule.setupRoutes(app);
|
||||
|
||||
app.get('/admin', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'public', 'admin.html'));
|
||||
});
|
||||
|
||||
// API: GET /api/leads – получить список всех заявок (требуется API-ключ)
|
||||
app.get('/api/leads', (req, res) => {
|
||||
const providedKey = req.headers['x-api-key'];
|
||||
if (!providedKey || providedKey !== API_KEY) {
|
||||
return res.status(401).json({ error: 'Invalid or missing API key' });
|
||||
}
|
||||
db.all(`SELECT id, name, phone, message, created_at FROM leads ORDER BY created_at DESC`, (err, rows) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
res.json(rows);
|
||||
});
|
||||
});
|
||||
|
||||
// API: DELETE /api/leads/:id – удалить заявку (требуется API-ключ)
|
||||
app.delete('/api/leads/:id', (req, res) => {
|
||||
const providedKey = req.headers['x-api-key'];
|
||||
if (!providedKey || providedKey !== API_KEY) {
|
||||
return res.status(401).json({ error: 'Invalid or missing API key' });
|
||||
}
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({ error: 'Invalid ID' });
|
||||
}
|
||||
const stmt = db.prepare(`DELETE FROM leads WHERE id = ?`);
|
||||
stmt.run(id, function(err) {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
if (this.changes === 0) {
|
||||
return res.status(404).json({ error: 'Lead not found' });
|
||||
}
|
||||
console.log(`🗑️ Удалена заявка #${id}`);
|
||||
res.json({ message: 'Lead deleted' });
|
||||
});
|
||||
stmt.finalize();
|
||||
});
|
||||
|
||||
// Serve frontend
|
||||
app.get('/', (req, res) => {
|
||||
const years = new Date().getFullYear() - 2011;
|
||||
const htmlPath = path.join(__dirname, 'public', 'index.html');
|
||||
@@ -137,8 +121,15 @@ app.get('/', (req, res) => {
|
||||
res.send(html.replace(/\{years\}/g, String(years)));
|
||||
});
|
||||
|
||||
app.listen(PORT, async () => {
|
||||
await convertImagesToWebp();
|
||||
console.log(`✅ server running on http://localhost:${PORT}`);
|
||||
console.log(`🔑 API Key: ${API_KEY}`);
|
||||
});
|
||||
convertImagesToWebp().then(() => console.log('Images ready'));
|
||||
|
||||
(async () => {
|
||||
await dbModule.init(db);
|
||||
await authModule.syncAdmin();
|
||||
console.log('Database initialized, admin synced');
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on http://localhost:${PORT}`);
|
||||
console.log(`Admin panel: http://localhost:${PORT}/admin`);
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user