This commit is contained in:
@@ -331,6 +331,26 @@ function initDatabase(db) {
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`, () => {
|
||||
setupVisitorLogTable();
|
||||
});
|
||||
}
|
||||
|
||||
function setupVisitorLogTable() {
|
||||
db.run(`CREATE TABLE IF NOT EXISTS visitor_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip TEXT NOT NULL,
|
||||
session_id TEXT,
|
||||
user_agent TEXT,
|
||||
accept_language TEXT,
|
||||
country TEXT,
|
||||
country_code TEXT,
|
||||
page_path TEXT,
|
||||
visit_date DATE DEFAULT (date('now')),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`, () => {
|
||||
db.run(`CREATE INDEX IF NOT EXISTS idx_visitor_log_session ON visitor_log(session_id)`);
|
||||
db.run(`CREATE INDEX IF NOT EXISTS idx_visitor_log_date ON visitor_log(visit_date)`);
|
||||
db.run(`CREATE INDEX IF NOT EXISTS idx_visitor_log_country ON visitor_log(country_code)`);
|
||||
setupIndices();
|
||||
});
|
||||
}
|
||||
|
||||
145
modules/visitors/index.js
Normal file
145
modules/visitors/index.js
Normal file
@@ -0,0 +1,145 @@
|
||||
let db;
|
||||
|
||||
function init(database) {
|
||||
db = database;
|
||||
}
|
||||
|
||||
function getStats(req, res) {
|
||||
db.get(`SELECT COUNT(DISTINCT session_id) as total FROM visitor_log`, [], (err, totalRow) => {
|
||||
if (err) return res.status(500).json({ error: err.message });
|
||||
db.get(`SELECT COUNT(DISTINCT session_id) as today FROM visitor_log WHERE visit_date = date('now')`, [], (err, todayRow) => {
|
||||
if (err) return res.status(500).json({ error: err.message });
|
||||
db.get(`SELECT COUNT(DISTINCT session_id) as week FROM visitor_log WHERE visit_date >= date('now', '-6 days')`, [], (err, weekRow) => {
|
||||
if (err) return res.status(500).json({ error: err.message });
|
||||
db.get(`SELECT COUNT(DISTINCT session_id) as month FROM visitor_log WHERE visit_date >= date('now', 'start of month')`, [], (err, monthRow) => {
|
||||
if (err) return res.status(500).json({ error: err.message });
|
||||
db.get(`SELECT COUNT(*) as page_views FROM visitor_log`, [], (err, pvRow) => {
|
||||
if (err) return res.status(500).json({ error: err.message });
|
||||
db.get(`SELECT COUNT(DISTINCT ip) as unique_ips FROM visitor_log`, [], (err, ipRow) => {
|
||||
if (err) return res.status(500).json({ error: err.message });
|
||||
res.json({
|
||||
total: totalRow ? totalRow.total : 0,
|
||||
today: todayRow ? todayRow.today : 0,
|
||||
week: weekRow ? weekRow.week : 0,
|
||||
month: monthRow ? monthRow.month : 0,
|
||||
pageViews: pvRow ? pvRow.page_views : 0,
|
||||
uniqueIps: ipRow ? ipRow.unique_ips : 0
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getCountries(req, res) {
|
||||
db.all(`SELECT COALESCE(NULLIF(country, ''), 'Другое') as country,
|
||||
COALESCE(NULLIF(country_code, ''), '--') as country_code,
|
||||
COUNT(DISTINCT session_id) as count
|
||||
FROM visitor_log
|
||||
GROUP BY COALESCE(NULLIF(country_code, ''), '--')
|
||||
ORDER BY count DESC LIMIT 30`, [], (err, rows) => {
|
||||
if (err) return res.status(500).json({ error: err.message });
|
||||
res.json(rows || []);
|
||||
});
|
||||
}
|
||||
|
||||
function getLanguages(req, res) {
|
||||
db.all(`SELECT accept_language, COUNT(DISTINCT session_id) as count
|
||||
FROM visitor_log
|
||||
WHERE accept_language IS NOT NULL AND accept_language != ''
|
||||
GROUP BY accept_language
|
||||
ORDER BY count DESC`, [], (err, rows) => {
|
||||
if (err) return res.status(500).json({ error: err.message });
|
||||
const parsed = (rows || []).map(r => {
|
||||
const primary = (r.accept_language || '').split(',')[0].split(';')[0].trim();
|
||||
const lang = primary.split('-')[0];
|
||||
return { lang, raw: primary, count: r.count };
|
||||
});
|
||||
res.json(parsed);
|
||||
});
|
||||
}
|
||||
|
||||
function getDaily(req, res) {
|
||||
db.all(`SELECT visit_date, COUNT(DISTINCT session_id) as count, COUNT(*) as page_views
|
||||
FROM visitor_log
|
||||
WHERE visit_date >= date('now', '-29 days')
|
||||
GROUP BY visit_date
|
||||
ORDER BY visit_date ASC`, [], (err, rows) => {
|
||||
if (err) return res.status(500).json({ error: err.message });
|
||||
res.json(rows || []);
|
||||
});
|
||||
}
|
||||
|
||||
function getIPs(req, res) {
|
||||
const limit = Math.min(parseInt(req.query.limit) || 50, 200);
|
||||
const offset = parseInt(req.query.offset) || 0;
|
||||
const { id, ip, country, lang, date_from, date_to, page } = req.query;
|
||||
|
||||
const whereClauses = [];
|
||||
const params = [];
|
||||
|
||||
if (id) { whereClauses.push('id = ?'); params.push(parseInt(id)); }
|
||||
if (ip) { whereClauses.push('ip LIKE ?'); params.push('%' + ip + '%'); }
|
||||
if (country) { whereClauses.push('(country LIKE ? OR country_code LIKE ?)'); params.push('%' + country + '%'); params.push('%' + country + '%'); }
|
||||
if (lang) { whereClauses.push('accept_language LIKE ?'); params.push('%' + lang + '%'); }
|
||||
if (date_from) { whereClauses.push('visit_date >= ?'); params.push(date_from); }
|
||||
if (date_to) { whereClauses.push('visit_date <= ?'); params.push(date_to); }
|
||||
if (page) { whereClauses.push('page_path LIKE ?'); params.push('%' + page + '%'); }
|
||||
|
||||
const whereSQL = whereClauses.length > 0 ? 'WHERE ' + whereClauses.join(' AND ') : '';
|
||||
|
||||
db.get(`SELECT COUNT(*) as total FROM visitor_log ${whereSQL}`, params, (err, countRow) => {
|
||||
if (err) return res.status(500).json({ error: err.message });
|
||||
|
||||
const allParams = [...params, limit, offset];
|
||||
db.all(`SELECT id, ip, COALESCE(NULLIF(country, ''), 'Другое') as country,
|
||||
country_code, session_id, visit_date, page_path,
|
||||
COALESCE(NULLIF(accept_language, ''), '-') as accept_language,
|
||||
created_at
|
||||
FROM visitor_log
|
||||
${whereSQL}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?`, allParams, (err, rows) => {
|
||||
if (err) return res.status(500).json({ error: err.message });
|
||||
res.json({
|
||||
entries: rows || [],
|
||||
total: countRow ? countRow.total : 0,
|
||||
limit,
|
||||
offset
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getFilterOptions(req, res) {
|
||||
db.all(`SELECT DISTINCT COALESCE(NULLIF(country, ''), 'Другое') as country
|
||||
FROM visitor_log ORDER BY country`, [], (err, countries) => {
|
||||
if (err) return res.status(500).json({ error: err.message });
|
||||
db.all(`SELECT DISTINCT accept_language FROM visitor_log
|
||||
WHERE accept_language IS NOT NULL AND accept_language != ''
|
||||
ORDER BY accept_language`, [], (err, langs) => {
|
||||
if (err) return res.status(500).json({ error: err.message });
|
||||
db.all(`SELECT DISTINCT page_path FROM visitor_log ORDER BY page_path`, [], (err, pages) => {
|
||||
if (err) return res.status(500).json({ error: err.message });
|
||||
res.json({
|
||||
countries: (countries || []).map(c => c.country),
|
||||
languages: (langs || []).map(l => l.accept_language),
|
||||
pages: (pages || []).map(p => p.page_path)
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setupRoutes(app, authenticateToken) {
|
||||
app.get('/api/admin/visitors/stats', authenticateToken, getStats);
|
||||
app.get('/api/admin/visitors/countries', authenticateToken, getCountries);
|
||||
app.get('/api/admin/visitors/languages', authenticateToken, getLanguages);
|
||||
app.get('/api/admin/visitors/daily', authenticateToken, getDaily);
|
||||
app.get('/api/admin/visitors/ips', authenticateToken, getIPs);
|
||||
app.get('/api/admin/visitors/filters', authenticateToken, getFilterOptions);
|
||||
}
|
||||
|
||||
module.exports = { init, setupRoutes };
|
||||
@@ -3,6 +3,7 @@
|
||||
"bcryptjs": "^3.0.3",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1",
|
||||
"geoip-lite": "^2.0.3",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-cron": "^4.2.1",
|
||||
|
||||
@@ -208,6 +208,7 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
|
||||
<a href="#" data-tab="seasonal"><i class="fas fa-tags"></i> Сезонные цены</a>
|
||||
<a href="#" data-tab="activities"><i class="fas fa-umbrella-beach"></i> Развлечения</a>
|
||||
<a href="#" data-tab="hero"><i class="fas fa-image"></i> Главный экран</a>
|
||||
<a href="#" data-tab="visitors"><i class="fas fa-chart-bar"></i> Посетители</a>
|
||||
<a href="#" data-tab="reviews"><i class="fas fa-star"></i> Отзывы</a>
|
||||
<a href="#" data-tab="settings"><i class="fas fa-cog"></i> Настройки</a>
|
||||
<a href="#" data-tab="profile"><i class="fas fa-user-circle"></i> Профиль</a>
|
||||
@@ -724,7 +725,6 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-hero" class="tab-content">
|
||||
<div class="top-bar">
|
||||
<h1>Главный экран</h1>
|
||||
@@ -736,6 +736,85 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-visitors" class="tab-content">
|
||||
<div class="top-bar">
|
||||
<h1>Посетители</h1>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom: 20px;">
|
||||
<div class="card-body-custom">
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(min(160px, 100%), 1fr)); gap: 16px;">
|
||||
<div class="stat-card" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color:#fff; padding: clamp(12px,2vw,20px); border-radius:12px; text-align:center;">
|
||||
<div style="font-size:0.85rem;opacity:0.9;margin-bottom:6px;">Всего посетителей</div>
|
||||
<div style="font-size:clamp(1.5rem,3vw,2.2rem);font-weight:700;" id="visTotal">-</div>
|
||||
</div>
|
||||
<div class="stat-card" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); color:#fff; padding: clamp(12px,2vw,20px); border-radius:12px; text-align:center;">
|
||||
<div style="font-size:0.85rem;opacity:0.9;margin-bottom:6px;">Сегодня</div>
|
||||
<div style="font-size:clamp(1.5rem,3vw,2.2rem);font-weight:700;" id="visToday">-</div>
|
||||
</div>
|
||||
<div class="stat-card" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); color:#fff; padding: clamp(12px,2vw,20px); border-radius:12px; text-align:center;">
|
||||
<div style="font-size:0.85rem;opacity:0.9;margin-bottom:6px;">За 7 дней</div>
|
||||
<div style="font-size:clamp(1.5rem,3vw,2.2rem);font-weight:700;" id="visWeek">-</div>
|
||||
</div>
|
||||
<div class="stat-card" style="background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%); color:#fff; padding: clamp(12px,2vw,20px); border-radius:12px; text-align:center;">
|
||||
<div style="font-size:0.85rem;opacity:0.9;margin-bottom:6px;">За месяц</div>
|
||||
<div style="font-size:clamp(1.5rem,3vw,2.2rem);font-weight:700;" id="visMonth">-</div>
|
||||
</div>
|
||||
<div class="stat-card" style="background: linear-gradient(135deg, #fa709a 0%, #fee140 100%); color:#fff; padding: clamp(12px,2vw,20px); border-radius:12px; text-align:center;">
|
||||
<div style="font-size:0.85rem;opacity:0.9;margin-bottom:6px;">Просмотров страниц</div>
|
||||
<div style="font-size:clamp(1.5rem,3vw,2.2rem);font-weight:700;" id="visPageViews">-</div>
|
||||
</div>
|
||||
<div class="stat-card" style="background: linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%); color:#fff; padding: clamp(12px,2vw,20px); border-radius:12px; text-align:center;">
|
||||
<div style="font-size:0.85rem;opacity:0.9;margin-bottom:6px;">Уникальных IP</div>
|
||||
<div style="font-size:clamp(1.5rem,3vw,2.2rem);font-weight:700;" id="visUniqueIps">-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
|
||||
<div class="card">
|
||||
<div class="card-body-custom">
|
||||
<h3 style="margin-bottom: 16px;">По странам</h3>
|
||||
<div id="visCountriesTable"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-body-custom">
|
||||
<h3 style="margin-bottom: 16px;">Языки браузеров</h3>
|
||||
<div id="visLanguagesTable"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top: 20px;">
|
||||
<div class="card-body-custom">
|
||||
<h3 style="margin-bottom: 16px;">Посещения за 30 дней</h3>
|
||||
<div id="visDailyChart" style="overflow-x: auto;">
|
||||
<canvas id="visitorsChart" style="width:100%;max-height:300px;"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top: 20px;">
|
||||
<div class="card-body-custom">
|
||||
<h3 style="margin-bottom: 16px;">IP-адреса и даты</h3>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(min(150px, 100%), 1fr)); gap: 10px; margin-bottom: 14px;">
|
||||
<div><label style="font-size:0.75rem;color:#64748b;display:block;margin-bottom:2px;">ID</label><input type="number" id="visFiltId" placeholder="123" style="width:100%;padding:6px 8px;border:1px solid #e5e7eb;border-radius:6px;font-size:0.85rem;" onchange="loadIPs(0)"></div>
|
||||
<div><label style="font-size:0.75rem;color:#64748b;display:block;margin-bottom:2px;">IP / маска</label><input type="text" id="visFiltIp" placeholder="192.168." style="width:100%;padding:6px 8px;border:1px solid #e5e7eb;border-radius:6px;font-size:0.85rem;" onchange="loadIPs(0)"></div>
|
||||
<div><label style="font-size:0.75rem;color:#64748b;display:block;margin-bottom:2px;">Страна</label><select id="visFiltCountry" style="width:100%;padding:6px 8px;border:1px solid #e5e7eb;border-radius:6px;font-size:0.85rem;" onchange="loadIPs(0)"><option value="">Все</option></select></div>
|
||||
<div><label style="font-size:0.75rem;color:#64748b;display:block;margin-bottom:2px;">Язык</label><select id="visFiltLang" style="width:100%;padding:6px 8px;border:1px solid #e5e7eb;border-radius:6px;font-size:0.85rem;" onchange="loadIPs(0)"><option value="">Все</option></select></div>
|
||||
<div><label style="font-size:0.75rem;color:#64748b;display:block;margin-bottom:2px;">Дата от</label><input type="date" id="visFiltDateFrom" style="width:100%;padding:6px 8px;border:1px solid #e5e7eb;border-radius:6px;font-size:0.85rem;" onchange="loadIPs(0)"></div>
|
||||
<div><label style="font-size:0.75rem;color:#64748b;display:block;margin-bottom:2px;">Дата до</label><input type="date" id="visFiltDateTo" style="width:100%;padding:6px 8px;border:1px solid #e5e7eb;border-radius:6px;font-size:0.85rem;" onchange="loadIPs(0)"></div>
|
||||
<div><label style="font-size:0.75rem;color:#64748b;display:block;margin-bottom:2px;">Страница</label><select id="visFiltPage" style="width:100%;padding:6px 8px;border:1px solid #e5e7eb;border-radius:6px;font-size:0.85rem;" onchange="loadIPs(0)"><option value="">Все</option></select></div>
|
||||
<div style="display:flex;align-items:flex-end;"><button onclick="loadIPs(0)" style="padding:6px 16px;background:#c9a84c;color:#fff;border:none;border-radius:6px;font-size:0.85rem;cursor:pointer;">Найти</button></div>
|
||||
</div>
|
||||
<div id="visIPsTable" style="overflow-x: auto;"></div>
|
||||
<div id="visIPsPagination" style="text-align:center;margin-top:12px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -911,6 +990,7 @@ function initTabs() {
|
||||
if (tab === 'seasonal') loadSeasonalPrices();
|
||||
if (tab === 'activities') loadActivitiesAdmin();
|
||||
if (tab === 'hero') loadHeroMediaAdmin();
|
||||
if (tab === 'visitors') loadVisitors();
|
||||
if (tab === 'reviews') loadReviews();
|
||||
if (tab === 'settings') loadSettings();
|
||||
if (tab === 'profile') loadProfile();
|
||||
@@ -2976,6 +3056,135 @@ function deleteHeroMedia(id) {
|
||||
.then(() => { loadHeroMediaAdmin(); showToast('Медиа удалено'); })
|
||||
.catch(err => showToast(err.message, 'error'));
|
||||
}
|
||||
|
||||
async function loadVisitors() {
|
||||
try {
|
||||
const stats = await api('/api/admin/visitors/stats');
|
||||
document.getElementById('visTotal').textContent = stats.total;
|
||||
document.getElementById('visToday').textContent = stats.today;
|
||||
document.getElementById('visWeek').textContent = stats.week;
|
||||
document.getElementById('visMonth').textContent = stats.month;
|
||||
document.getElementById('visPageViews').textContent = stats.pageViews;
|
||||
document.getElementById('visUniqueIps').textContent = stats.uniqueIps;
|
||||
|
||||
const countries = await api('/api/admin/visitors/countries');
|
||||
const ct = document.getElementById('visCountriesTable');
|
||||
if (countries.length === 0) {
|
||||
ct.innerHTML = '<div style="text-align:center;color:#94a3b8;padding:20px;">Нет данных</div>';
|
||||
} else {
|
||||
ct.innerHTML = '<table style="width:100%;border-collapse:collapse;"><thead><tr><th style="text-align:left;padding:8px;border-bottom:2px solid #e5e7eb;"><3E><>трана</th><th style="text-align:right;padding:8px;border-bottom:2px solid #e5e7eb;"><3E><>осетителей</th></tr></thead><tbody>' +
|
||||
countries.map(c => `<tr><td style="padding:6px 8px;border-bottom:1px solid #f1f5f9;">${c.country || '—'} <span style="color:#94a3b8;font-size:0.75rem;">${c.country_code || ''}</span></td><td style="text-align:right;font-weight:600;padding:6px 8px;border-bottom:1px solid #f1f5f9;">${c.count}</td></tr>`).join('') +
|
||||
'</tbody></table>';
|
||||
}
|
||||
|
||||
const langs = await api('/api/admin/visitors/languages');
|
||||
const lt = document.getElementById('visLanguagesTable');
|
||||
if (langs.length === 0) {
|
||||
lt.innerHTML = '<div style="text-align:center;color:#94a3b8;padding:20px;">Нет данных</div>';
|
||||
} else {
|
||||
lt.innerHTML = '<table style="width:100%;border-collapse:collapse;"><thead><tr><th style="text-align:left;padding:8px;border-bottom:2px solid #e5e7eb;">Язык</th><th style="text-align:right;padding:8px;border-bottom:2px solid #e5e7eb;">Посетителей</th></tr></thead><tbody>' +
|
||||
langs.map(l => `<tr><td style="padding:6px 8px;border-bottom:1px solid #f1f5f9;">${l.raw || l.lang || '—'}</td><td style="text-align:right;font-weight:600;padding:6px 8px;border-bottom:1px solid #f1f5f9;">${l.count}</td></tr>`).join('') +
|
||||
'</tbody></table>';
|
||||
}
|
||||
|
||||
const daily = await api('/api/admin/visitors/daily');
|
||||
if (daily.length > 0) {
|
||||
let html = '<table style="width:100%;border-collapse:collapse;font-size:0.9rem;"><thead><tr><th style="text-align:left;padding:8px;border-bottom:2px solid #e5e7eb;">Дата</th><th style="text-align:right;padding:8px;border-bottom:2px solid #e5e7eb;">Посетителей</th><th style="text-align:right;padding:8px;border-bottom:2px solid #e5e7eb;">Просмотров</th></tr></thead><tbody>';
|
||||
daily.reverse().forEach(d => {
|
||||
html += `<tr><td style="padding:5px 8px;border-bottom:1px solid #f1f5f9;">${d.visit_date}</td><td style="text-align:right;font-weight:600;padding:5px 8px;border-bottom:1px solid #f1f5f9;">${d.count}</td><td style="text-align:right;padding:5px 8px;border-bottom:1px solid #f1f5f9;">${d.page_views}</td></tr>`;
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
document.getElementById('visDailyChart').innerHTML = html;
|
||||
}
|
||||
|
||||
await loadFilterOptions();
|
||||
loadIPs(0);
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function fillSelect(id, items) {
|
||||
const sel = document.getElementById(id);
|
||||
if (!sel) return;
|
||||
const val = sel.value;
|
||||
sel.innerHTML = '<option value="">Все</option>';
|
||||
items.forEach(item => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = item;
|
||||
opt.textContent = item;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
if (val) sel.value = val;
|
||||
}
|
||||
|
||||
async function loadFilterOptions() {
|
||||
try {
|
||||
const data = await api('/api/admin/visitors/filters');
|
||||
fillSelect('visFiltCountry', data.countries || []);
|
||||
fillSelect('visFiltLang', data.languages || []);
|
||||
fillSelect('visFiltPage', data.pages || []);
|
||||
} catch (e) {
|
||||
// silently fail, filters stay as "Все"
|
||||
}
|
||||
}
|
||||
|
||||
async function loadIPs(offset) {
|
||||
const limit = 50;
|
||||
document.getElementById('visIPsTable').innerHTML = '<div style="text-align:center;color:#94a3b8;padding:20px;">Загрузка...</div>';
|
||||
try {
|
||||
const filters = {
|
||||
id: document.getElementById('visFiltId').value.trim(),
|
||||
ip: document.getElementById('visFiltIp').value.trim(),
|
||||
country: document.getElementById('visFiltCountry').value.trim(),
|
||||
lang: document.getElementById('visFiltLang').value.trim(),
|
||||
date_from: document.getElementById('visFiltDateFrom').value,
|
||||
date_to: document.getElementById('visFiltDateTo').value,
|
||||
page: document.getElementById('visFiltPage').value.trim()
|
||||
};
|
||||
let qs = `limit=${limit}&offset=${offset}`;
|
||||
Object.keys(filters).forEach(k => {
|
||||
if (filters[k]) qs += '&' + k + '=' + encodeURIComponent(filters[k]);
|
||||
});
|
||||
const data = await api('/api/admin/visitors/ips?' + qs);
|
||||
if (data.entries.length === 0) {
|
||||
document.getElementById('visIPsTable').innerHTML = '<div style="text-align:center;color:#94a3b8;padding:20px;">Нет данных</div>';
|
||||
document.getElementById('visIPsPagination').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
let html = '<table style="width:100%;border-collapse:collapse;font-size:0.85rem;"><thead><tr>' +
|
||||
'<th style="text-align:left;padding:6px 8px;border-bottom:2px solid #e5e7eb;">IP</th>' +
|
||||
'<th style="text-align:left;padding:6px 8px;border-bottom:2px solid #e5e7eb;">Страна</th>' +
|
||||
'<th style="text-align:left;padding:6px 8px;border-bottom:2px solid #e5e7eb;">Язык</th>' +
|
||||
'<th style="text-align:left;padding:6px 8px;border-bottom:2px solid #e5e7eb;">Дата</th>' +
|
||||
'<th style="text-align:left;padding:6px 8px;border-bottom:2px solid #e5e7eb;">Страница</th>' +
|
||||
'</tr></thead><tbody>';
|
||||
data.entries.forEach(e => {
|
||||
html += `<tr>` +
|
||||
`<td style="padding:4px 8px;border-bottom:1px solid #f1f5f9;font-family:monospace;">${e.ip}</td>` +
|
||||
`<td style="padding:4px 8px;border-bottom:1px solid #f1f5f9;">${e.country || '-'} <span style="color:#94a3b8;font-size:0.7rem;">${e.country_code || ''}</span></td>` +
|
||||
`<td style="padding:4px 8px;border-bottom:1px solid #f1f5f9;">${e.accept_language || '-'}</td>` +
|
||||
`<td style="padding:4px 8px;border-bottom:1px solid #f1f5f9;white-space:nowrap;">${e.created_at ? e.created_at.substring(0, 16) : e.visit_date}</td>` +
|
||||
`<td style="padding:4px 8px;border-bottom:1px solid #f1f5f9;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:0.8rem;color:#64748b;" title="${e.page_path}">${e.page_path}</td>` +
|
||||
`</tr>`;
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
document.getElementById('visIPsTable').innerHTML = html;
|
||||
|
||||
const totalPages = Math.ceil(data.total / limit);
|
||||
const currentPage = Math.floor(offset / limit) + 1;
|
||||
let paginationHtml = `<span style="color:#94a3b8;font-size:0.85rem;">Строк: ${data.total} · Стр. ${currentPage} из ${totalPages}</span> `;
|
||||
if (offset > 0) {
|
||||
paginationHtml += `<button onclick="loadIPs(${Math.max(0, offset - limit)})" style="padding:4px 12px;border:1px solid #e5e7eb;border-radius:6px;background:#fff;cursor:pointer;margin:0 4px;">← Назад</button>`;
|
||||
}
|
||||
if (offset + limit < data.total) {
|
||||
paginationHtml += `<button onclick="loadIPs(${offset + limit})" style="padding:4px 12px;border:1px solid #e5e7eb;border-radius:6px;background:#fff;cursor:pointer;margin:0 4px;">Вперёд →</button>`;
|
||||
}
|
||||
document.getElementById('visIPsPagination').innerHTML = paginationHtml;
|
||||
} catch (err) {
|
||||
document.getElementById('visIPsTable').innerHTML = '<div style="text-align:center;color:#ef4444;padding:20px;">Ошибка загрузки</div>';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Room Edit Modal -->
|
||||
|
||||
64
server.js
64
server.js
@@ -188,6 +188,66 @@ app.use((req, res, next) => {
|
||||
if (req.method === 'OPTIONS') return res.sendStatus(200);
|
||||
next();
|
||||
});
|
||||
|
||||
const geoip = require('geoip-lite');
|
||||
const ipCountryCache = {};
|
||||
|
||||
function getCountryByIp(ip) {
|
||||
if (!ip || ip === '127.0.0.1' || ip === '::1' || ip.startsWith('::ffff:127.')) return null;
|
||||
if (ipCountryCache[ip] !== undefined) return ipCountryCache[ip];
|
||||
try {
|
||||
const geo = geoip.lookup(ip);
|
||||
const result = geo ? { country: geo.country, code: geo.country } : null;
|
||||
ipCountryCache[ip] = result;
|
||||
return result;
|
||||
} catch (e) {
|
||||
ipCountryCache[ip] = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseCookies(header) {
|
||||
const cookies = {};
|
||||
if (!header) return cookies;
|
||||
header.split(';').forEach(c => {
|
||||
const idx = c.indexOf('=');
|
||||
if (idx > 0) cookies[c.substring(0, idx).trim()] = c.substring(idx + 1).trim();
|
||||
});
|
||||
return cookies;
|
||||
}
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (req.method !== 'GET' || req.path.startsWith('/api/') ||
|
||||
req.path.startsWith('/css/') || req.path.startsWith('/js/') ||
|
||||
req.path.startsWith('/img/') || req.path.startsWith('/data/') ||
|
||||
req.path.startsWith('/uploads/') || req.path.startsWith('/webfonts/') ||
|
||||
req.path === '/metrics' || req.path === '/favicon.ico') {
|
||||
return next();
|
||||
}
|
||||
|
||||
const ip = req.ip || req.connection.remoteAddress || '';
|
||||
const cookies = parseCookies(req.headers.cookie);
|
||||
let sessionId = cookies.visitor_sid;
|
||||
|
||||
if (!sessionId) {
|
||||
sessionId = require('crypto').randomUUID();
|
||||
res.setHeader('Set-Cookie', `visitor_sid=${sessionId}; Path=/; Max-Age=86400; SameSite=Lax`);
|
||||
}
|
||||
|
||||
const geo = getCountryByIp(ip);
|
||||
|
||||
db.run(
|
||||
`INSERT INTO visitor_log (ip, session_id, user_agent, accept_language, country, country_code, page_path, visit_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, date('now'))`,
|
||||
[ip, sessionId, (req.headers['user-agent'] || '').substring(0, 500),
|
||||
(req.headers['accept-language'] || '').substring(0, 200),
|
||||
geo ? geo.country : null, geo ? geo.code : null, req.path],
|
||||
(err) => { if (err) console.error('Visitor log error:', err.message); }
|
||||
);
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
|
||||
app.use('/data/room_images', express.static(roomsUploadsDir));
|
||||
@@ -272,6 +332,7 @@ const syncModule = require('./modules/sync');
|
||||
const reportsModule = require('./modules/reports');
|
||||
const activitiesModule = require('./modules/activities');
|
||||
const heroModule = require('./modules/hero');
|
||||
const visitorsModule = require('./modules/visitors');
|
||||
const { runStartupTests } = require('./tests/runStartupTests');
|
||||
|
||||
modules.auth = authModule;
|
||||
@@ -291,6 +352,7 @@ modules.sync = syncModule;
|
||||
modules.reports = reportsModule;
|
||||
modules.activities = activitiesModule;
|
||||
modules.hero = heroModule;
|
||||
modules.visitors = visitorsModule;
|
||||
|
||||
authModule.init(db, JWT_SECRET);
|
||||
bookingsModule.init(db);
|
||||
@@ -308,6 +370,7 @@ syncModule.init(db, settingsModule, emailModule, telegramModule);
|
||||
reportsModule.init(db);
|
||||
activitiesModule.init(db);
|
||||
heroModule.init(db);
|
||||
visitorsModule.init(db);
|
||||
|
||||
function initDefaultRooms() {
|
||||
db.get("SELECT COUNT(*) as count FROM rooms", (err, row) => {
|
||||
@@ -382,6 +445,7 @@ telegramModule.setupRoutes(app, authModule.authenticateToken, authModule.require
|
||||
reportsModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
|
||||
activitiesModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin, uploadActivity);
|
||||
heroModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin, uploadHeroImage, uploadHeroVideo);
|
||||
visitorsModule.setupRoutes(app, authModule.authenticateToken);
|
||||
|
||||
app.get('/api/translations/:lang', (req, res) => {
|
||||
const lang = req.params.lang;
|
||||
|
||||
Reference in New Issue
Block a user