/**
* Plants vs. Zombies — Admin Panel & Game Configurator Client
* Handles Admin Authentication, REST API communication, Sliders, Live Events,
* User Management, Presets, Bug Moderation, Campaign Level Editor,
* Active Player Sessions, Login Audit Logs, and Backups.
*/
class PvZConfiguratorApp {
constructor() {
this.token = localStorage.getItem('pvz_admin_token') || '';
this.isAuthenticated = false;
this.config = null;
this.stats = null;
this.users = [];
this.bugs = [];
this.levels = [];
this.selectedLevel = null;
this.sessions = [];
this.loginLogs = [];
this.activeTab = 'tab-dashboard';
this.init();
}
async init() {
this.bindEvents();
this.setupSliders();
// Check initial auth status
if (this.token) {
const valid = await this.verifyAuth();
if (valid) {
this.onAuthenticated();
} else {
this.onUnauthenticated();
}
} else {
this.onUnauthenticated();
}
}
// ==========================================
// Toast Alerts
// ==========================================
showToast(message, type = 'success') {
const container = document.getElementById('toast-container');
if (!container) return;
const toast = document.createElement('div');
toast.className = `toast ${type}`;
const icon = type === 'success' ? '✅' : (type === 'error' ? '❌' : (type === 'warn' ? '⚠️' : 'ℹ️'));
toast.innerHTML = `${icon} ${message}`;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateX(100%)';
toast.style.transition = 'all 0.3s ease';
setTimeout(() => toast.remove(), 300);
}, 3500);
}
// ==========================================
// Authentication & API Helpers
// ==========================================
async apiRequest(endpoint, method = 'GET', body = null) {
const headers = {
'Content-Type': 'application/json'
};
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
headers['X-Admin-Token'] = this.token;
}
const options = { method, headers };
if (body) {
options.body = JSON.stringify(body);
}
try {
const res = await fetch(endpoint, options);
const data = await res.json();
return { ok: res.ok, status: res.status, data };
} catch (err) {
return { ok: false, status: 0, data: { message: err.message || 'Ошибка сети' } };
}
}
async verifyAuth() {
const res = await this.apiRequest('/api/admin/stats');
if (res.ok && res.data.success) {
this.stats = res.data.stats;
this.isAuthenticated = true;
return true;
}
this.isAuthenticated = false;
this.token = '';
localStorage.removeItem('pvz_admin_token');
return false;
}
onAuthenticated() {
this.isAuthenticated = true;
const loginScreen = document.getElementById('configurator-login-screen');
if (loginScreen) loginScreen.style.display = 'none';
const appContainer = document.getElementById('configurator-app-container');
if (appContainer) appContainer.style.display = 'block';
const dot = document.getElementById('admin-status-dot');
if (dot) dot.className = 'status-dot';
const stText = document.getElementById('admin-status-text');
if (stText) stText.textContent = 'Администратор';
const btnLoginModal = document.getElementById('btn-login-modal-open');
if (btnLoginModal) btnLoginModal.style.display = 'none';
const btnPwd = document.getElementById('btn-admin-password-modal-open');
if (btnPwd) btnPwd.style.display = 'inline-flex';
const btnLogout = document.getElementById('btn-admin-logout');
if (btnLogout) btnLogout.style.display = 'inline-flex';
this.loadAllData();
}
onUnauthenticated() {
this.isAuthenticated = false;
this.token = '';
localStorage.removeItem('pvz_admin_token');
this.config = null;
this.stats = null;
this.users = [];
this.sessions = [];
this.loginLogs = [];
this.bugs = [];
this.levels = [];
const appContainer = document.getElementById('configurator-app-container');
if (appContainer) appContainer.style.display = 'none';
const loginScreen = document.getElementById('configurator-login-screen');
if (loginScreen) loginScreen.style.display = 'flex';
const loginErr = document.getElementById('login-screen-error');
if (loginErr) {
loginErr.style.display = 'none';
loginErr.textContent = '';
}
const pwdInput = document.getElementById('login-screen-password');
if (pwdInput) pwdInput.value = '';
const dot = document.getElementById('admin-status-dot');
if (dot) dot.className = 'status-dot offline';
const stText = document.getElementById('admin-status-text');
if (stText) stText.textContent = 'Гость (Только чтение)';
const btnLoginModal = document.getElementById('btn-login-modal-open');
if (btnLoginModal) btnLoginModal.style.display = 'inline-flex';
const btnPwd = document.getElementById('btn-admin-password-modal-open');
if (btnPwd) btnPwd.style.display = 'none';
const btnLogout = document.getElementById('btn-admin-logout');
if (btnLogout) btnLogout.style.display = 'none';
}
openModal(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.add('active', 'open');
}
}
closeModal(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.remove('active', 'open');
}
}
async handleScreenLogin() {
const pwdInput = document.getElementById('login-screen-password');
const errDiv = document.getElementById('login-screen-error');
const enteredPassword = pwdInput ? pwdInput.value.trim() : '';
if (!enteredPassword) {
if (errDiv) {
errDiv.textContent = 'Введите мастер-пароль администратора';
errDiv.style.display = 'flex';
}
return;
}
if (errDiv) {
errDiv.style.display = 'none';
errDiv.textContent = '';
}
const res = await this.apiRequest('/api/admin/auth', 'POST', { password: enteredPassword });
if (res.ok && res.data && res.data.success && res.data.token) {
this.token = res.data.token;
localStorage.setItem('pvz_admin_token', this.token);
this.showToast('Авторизация успешна! Добро пожаловать в панель управления.', 'success');
this.onAuthenticated();
} else {
if (errDiv) {
errDiv.textContent = (res.data && res.data.message) || 'Неверный пароль администратора';
errDiv.style.display = 'flex';
}
}
}
async loadPublicConfig() {
const res = await this.apiRequest('/api/config');
if (res.ok) {
this.config = res.data;
this.populateUIFromConfig();
}
}
async loadAllData() {
await Promise.all([
this.loadStats(),
this.loadConfig(),
this.loadUsers(),
this.loadBugs(),
this.loadCampaignLevels(),
this.loadSessions(),
this.loadLoginLogs()
]);
}
// ==========================================
// Data Fetching
// ==========================================
async loadStats() {
const res = await this.apiRequest('/api/admin/stats');
if (res.ok && res.data.success) {
this.stats = res.data.stats;
this.renderDashboard();
}
}
async loadConfig() {
const res = await this.apiRequest('/api/admin/config');
if (res.ok && res.data.success) {
this.config = res.data.config;
this.populateUIFromConfig();
}
}
async loadUsers() {
const res = await this.apiRequest('/api/admin/users');
if (res.ok && res.data.success) {
this.users = res.data.users || [];
this.renderPlayersTable();
}
}
async loadBugs() {
const res = await this.apiRequest('/api/admin/bugs');
if (res.ok && res.data.success) {
this.bugs = res.data.bugs || [];
this.renderBugsTable();
}
}
// ==========================================
// UI Rendering & Sliders
// ==========================================
setupSliders() {
document.querySelectorAll('.range-slider').forEach(slider => {
slider.addEventListener('input', (e) => {
const id = e.target.id;
const val = parseFloat(e.target.value);
const badge = document.getElementById(`val-${id}`);
if (badge) {
if (id === 'econ-startingSun') {
badge.textContent = `${val} ☀️`;
} else if (id === 'zen-waterIntervalMinutes') {
badge.textContent = `${val} мин`;
} else {
badge.textContent = `${val.toFixed(1)}x`;
}
}
});
});
}
setSliderValue(id, value, suffix = 'x') {
const el = document.getElementById(id);
const badge = document.getElementById(`val-${id}`);
if (el) el.value = value;
if (badge) {
if (suffix === 'sun') {
badge.textContent = `${value} ☀️`;
} else if (suffix === 'min') {
badge.textContent = `${value} мин`;
} else {
badge.textContent = `${parseFloat(value).toFixed(1)}x`;
}
}
}
populateUIFromConfig() {
if (!this.config) return;
// 1. Economy
const eco = this.config.economy || {};
this.setSliderValue('econ-sunMultiplier', eco.sunMultiplier ?? 1.0);
this.setSliderValue('econ-startingSun', eco.startingSun ?? 50, 'sun');
this.setSliderValue('econ-coinDropRate', eco.coinDropRate ?? 1.0);
this.setSliderValue('econ-sunDropRate', eco.sunDropRate ?? 1.0);
this.setSliderValue('econ-plantCostMultiplier', eco.plantCostMultiplier ?? 1.0);
this.setSliderValue('econ-upgradeCostMultiplier', eco.upgradeCostMultiplier ?? 1.0);
// 2. Zombies
const zom = this.config.zombies || {};
this.setSliderValue('zom-hpMultiplier', zom.hpMultiplier ?? 1.0);
this.setSliderValue('zom-speedMultiplier', zom.speedMultiplier ?? 1.0);
this.setSliderValue('zom-damageMultiplier', zom.damageMultiplier ?? 1.0);
this.setSliderValue('zom-waveDensityMultiplier', zom.waveDensityMultiplier ?? 1.0);
// 3. Plants
const plt = this.config.plants || {};
this.setSliderValue('plant-damageMultiplier', plt.damageMultiplier ?? 1.0);
this.setSliderValue('plant-hpMultiplier', plt.hpMultiplier ?? 1.0);
this.setSliderValue('plant-cooldownMultiplier', plt.cooldownMultiplier ?? 1.0);
this.setSliderValue('plant-sunProduceIntervalMultiplier', plt.sunProduceIntervalMultiplier ?? 1.0);
// 4. Zen Garden
const zen = this.config.zenGarden || {};
this.setSliderValue('zen-dropRateMultiplier', zen.dropRateMultiplier ?? 1.0);
this.setSliderValue('zen-growthRateMultiplier', zen.growthRateMultiplier ?? 1.0);
this.setSliderValue('zen-fertilizerMultiplier', zen.fertilizerMultiplier ?? 1.0);
this.setSliderValue('zen-waterIntervalMinutes', zen.waterIntervalMinutes ?? 30, 'min');
// 5. Events
const ev = this.config.events || {};
document.getElementById('event-doubleGold').checked = !!ev.doubleGold;
document.getElementById('event-fastCooldowns').checked = !!ev.fastCooldowns;
document.getElementById('event-infiniteSun').checked = !!ev.infiniteSun;
document.getElementById('event-turboZombies').checked = !!ev.turboZombies;
}
renderDashboard() {
if (!this.stats) return;
document.getElementById('dash-total-users').textContent = this.stats.totalUsers ?? 0;
document.getElementById('dash-active-24h').textContent = this.stats.active24h ?? 0;
document.getElementById('dash-total-coins').textContent = (this.stats.totalCoins ?? 0).toLocaleString('ru-RU');
document.getElementById('dash-banned-users').textContent = this.stats.bannedUsers ?? 0;
document.getElementById('dash-total-bugs').textContent = this.stats.totalBugs ?? (this.bugs ? this.bugs.length : 0);
document.getElementById('dash-active-preset').textContent = this.stats.activeConfig?.preset || this.config?.preset || 'Classic 2009';
const sCount = document.getElementById('dash-active-sessions');
if (sCount) sCount.textContent = this.stats.activeSessions ?? (this.sessions ? this.sessions.length : 0);
const lCount = document.getElementById('dash-total-logs');
if (lCount) lCount.textContent = this.stats.totalLoginLogs ?? (this.loginLogs ? this.loginLogs.length : 0);
const cCount = document.getElementById('dash-campaign-levels');
if (cCount) cCount.textContent = this.stats.campaignLevelsCount ?? (this.levels ? this.levels.length : 25);
const zCount = document.getElementById('dash-total-zombies');
if (zCount) zCount.textContent = (this.stats.totalZombiesKilled ?? 0).toLocaleString('ru-RU');
const pCount = document.getElementById('dash-total-plants');
if (pCount) pCount.textContent = (this.stats.totalPlantsPlanted ?? 0).toLocaleString('ru-RU');
const sunCount = document.getElementById('dash-total-sun');
if (sunCount) sunCount.textContent = (this.stats.totalSunProduced ?? 0).toLocaleString('ru-RU');
}
// ==========================================
// CAMPAIGN LEVEL EDITOR TAB
// ==========================================
async loadCampaignLevels() {
const res = await this.apiRequest('/api/admin/campaign/levels');
if (res.ok && res.data.success) {
this.levels = res.data.levels || [];
this.renderLevelSelector();
if (!this.selectedLevel && this.levels.length > 0) {
this.selectLevel(1);
} else if (this.selectedLevel) {
this.selectLevel(this.selectedLevel.level);
}
}
}
renderLevelSelector() {
const dropdown = document.getElementById('level-select-dropdown');
const grid = document.getElementById('levels-grid-selector');
if (!dropdown || !grid) return;
dropdown.innerHTML = '';
grid.innerHTML = '';
const worldIcons = { 1: '☀️', 2: '🌙', 3: '🏊', 4: '🌫️', 5: '🏠' };
const worldNames = { 1: 'Солнечный День', 2: 'Ночной Двор', 3: 'Бассейн', 4: 'Ночной Туман', 5: 'Крыша Дома' };
this.levels.forEach(lvl => {
const wIcon = worldIcons[lvl.world] || '🌱';
const opt = document.createElement('option');
opt.value = lvl.level;
opt.textContent = `${lvl.code}: ${lvl.title} (${wIcon} ${lvl.biome})`;
dropdown.appendChild(opt);
const pill = document.createElement('div');
pill.className = `level-pill ${lvl.isBoss || lvl.is_boss ? 'is-boss' : ''} ${this.selectedLevel?.level === lvl.level ? 'active' : ''}`;
pill.id = `level-pill-${lvl.level}`;
pill.title = `${lvl.code} - ${lvl.title} (${worldNames[lvl.world] || 'Мир ' + lvl.world})`;
pill.innerHTML = `
${lvl.code}
${lvl.isBoss || lvl.is_boss ? '👑' : wIcon}
`;
pill.addEventListener('click', () => this.selectLevel(lvl.level));
grid.appendChild(pill);
});
dropdown.onchange = (e) => {
this.selectLevel(parseInt(e.target.value, 10));
};
}
selectLevel(levelNum) {
const lvl = this.levels.find(l => l.level === parseInt(levelNum, 10)) || this.levels[0];
if (!lvl) return;
this.selectedLevel = JSON.parse(JSON.stringify(lvl));
// Update active UI badges and chips
document.querySelectorAll('.level-pill').forEach(p => p.classList.remove('active'));
const activePill = document.getElementById(`level-pill-${lvl.level}`);
if (activePill) activePill.classList.add('active');
const dropdown = document.getElementById('level-select-dropdown');
if (dropdown) dropdown.value = lvl.level;
const worldNames = { 1: '☀️ Солнечный День', 2: '🌙 Ночной Двор', 3: '🏊 Бассейн', 4: '🌫️ Ночной Туман', 5: '🏠 Крыша Дома' };
document.getElementById('current-level-badge-code').textContent = lvl.code || `${lvl.world}-${lvl.stage}`;
document.getElementById('current-level-badge-world').textContent = worldNames[lvl.world] || `Мир ${lvl.world}`;
// Fill Basic Info
document.getElementById('level-code').value = lvl.code || '';
document.getElementById('level-number').value = lvl.level || 1;
document.getElementById('level-title').value = lvl.title || '';
document.getElementById('level-desc').value = lvl.desc || '';
document.getElementById('level-world').value = lvl.world || 1;
document.getElementById('level-biome').value = lvl.biome || 'day';
document.getElementById('level-reward-coins').value = lvl.reward ?? 100;
document.getElementById('level-reward-crystals').value = lvl.rewardCrystals ?? (lvl.reward_crystals ?? 0);
document.getElementById('level-is-boss').checked = !!(lvl.isBoss || lvl.is_boss);
// Fill Modifiers
const mods = lvl.modifiers || {};
this.setSliderValue('mod-zombie-hp', mods.zombieHpMult ?? 1.0);
this.setSliderValue('mod-zombie-speed', mods.zombieSpeedMult ?? 1.0);
document.getElementById('mod-startingSun') ? (document.getElementById('mod-startingSun').value = mods.startingSun ?? 50) : null;
document.getElementById('mod-starting-sun').value = mods.startingSun ?? 50;
document.getElementById('mod-special-event').value = mods.specialEvent || 'none';
document.getElementById('mod-conveyor-deck').checked = !!mods.conveyorDeck;
document.getElementById('mod-fast-recharge').checked = !!mods.fastRecharge;
// Fill Dialogue
const dia = lvl.dialogue || {};
document.getElementById('dialogue-speaker').value = dia.speaker || '🧔';
document.getElementById('dialogue-title').value = dia.title || '';
document.getElementById('dialogue-text').value = dia.text || '';
document.getElementById('dialogue-btn-text').value = dia.btnText || 'ДАЛЕЕ / В БОЙ ⚔️';
this.updateDialoguePreview();
// Render Waves
this.renderWavesConfigurator();
}
updateDialoguePreview() {
const speaker = document.getElementById('dialogue-speaker').value || '🧔';
const title = document.getElementById('dialogue-title').value || 'Диалог перед боем';
const text = document.getElementById('dialogue-text').value || '(Реплика отсутствует)';
const btn = document.getElementById('dialogue-btn-text').value || 'ДАЛЕЕ / В БОЙ ⚔️';
document.getElementById('dialogue-preview-speaker').textContent = speaker;
document.getElementById('dialogue-preview-title').textContent = title;
document.getElementById('dialogue-preview-text').textContent = text;
document.getElementById('dialogue-preview-btn').textContent = btn;
}
renderWavesConfigurator() {
const container = document.getElementById('level-waves-container');
if (!container) return;
container.innerHTML = '';
let waves = this.selectedLevel?.waves;
if (!waves || (Array.isArray(waves) && waves.length === 0) || (typeof waves === 'object' && Object.keys(waves).length === 0)) {
waves = [
{ title: 'Волна 1', isFlag: false, zombies: [{ type: 'normal', count: 5, interval: 3.5 }] },
{ title: 'Волна 2', isFlag: false, zombies: [{ type: 'normal', count: 6, interval: 3.0 }, { type: 'conehead', count: 2, interval: 4.0 }] },
{ title: 'Финальная волна 🏁', isFlag: true, zombies: [{ type: 'flag', count: 1, interval: 1.0 }, { type: 'buckethead', count: 2, interval: 3.0 }, { type: 'normal', count: 8, interval: 2.0 }] }
];
this.selectedLevel.waves = waves;
}
const wavesArray = Array.isArray(waves) ? waves : Object.values(waves);
const zombieTypes = [
{ id: 'normal', name: '🧟 Обычный зомби' },
{ id: 'conehead', name: '🪖 Зомби с конусом' },
{ id: 'buckethead', name: '🪣 Зомби с ведром' },
{ id: 'newspaper', name: '📰 Зомби с газетой' },
{ id: 'screendoor', name: '🚪 Зомби с дверью' },
{ id: 'football', name: '🏈 Зомби-футболист' },
{ id: 'dancing', name: '🕺 Танцующий зомби' },
{ id: 'duckytube', name: '🦆 Зомби на уточке' },
{ id: 'polevaulter', name: '🏃 Прыгун с шестом' },
{ id: 'flag', name: '🚩 Флагманский зомби' },
{ id: 'gargantuar', name: '👹 Гаргантюа' },
{ id: 'zomboss', name: '🤖 Доктор Зомбосс' }
];
wavesArray.forEach((wave, wIdx) => {
const card = document.createElement('div');
card.className = `wave-card ${wave.isFlag ? 'is-final' : ''}`;
card.dataset.waveIndex = wIdx;
let zombiesHtml = '';
const zList = wave.zombies || [{ type: 'normal', count: 4, interval: 3.0 }];
zList.forEach((z, zIdx) => {
let optHtml = '';
zombieTypes.forEach(t => {
optHtml += ``;
});
zombiesHtml += `
`;
});
card.innerHTML = `
${zombiesHtml}
`;
// Event handlers for this wave card
card.querySelector('.btn-add-zombie-to-wave').addEventListener('click', () => {
this.addZombieToWave(wIdx);
});
card.querySelector('.btn-remove-wave').addEventListener('click', () => {
this.removeWave(wIdx);
});
card.querySelectorAll('.btn-remove-zombie-entry').forEach((btn, zIdx) => {
btn.addEventListener('click', () => {
this.removeZombieFromWave(wIdx, zIdx);
});
});
container.appendChild(card);
});
}
addZombieToWave(waveIdx) {
this.collectCurrentLevelData();
let waves = this.selectedLevel.waves;
const wavesArr = Array.isArray(waves) ? waves : Object.values(waves);
if (wavesArr[waveIdx]) {
if (!wavesArr[waveIdx].zombies) wavesArr[waveIdx].zombies = [];
wavesArr[waveIdx].zombies.push({ type: 'conehead', count: 2, interval: 3.0 });
this.selectedLevel.waves = wavesArr;
this.renderWavesConfigurator();
}
}
removeZombieFromWave(waveIdx, zombieIdx) {
this.collectCurrentLevelData();
let waves = this.selectedLevel.waves;
const wavesArr = Array.isArray(waves) ? waves : Object.values(waves);
if (wavesArr[waveIdx] && wavesArr[waveIdx].zombies) {
wavesArr[waveIdx].zombies.splice(zombieIdx, 1);
if (wavesArr[waveIdx].zombies.length === 0) {
wavesArr[waveIdx].zombies.push({ type: 'normal', count: 1, interval: 3.0 });
}
this.selectedLevel.waves = wavesArr;
this.renderWavesConfigurator();
}
}
addWave() {
this.collectCurrentLevelData();
let waves = this.selectedLevel.waves;
const wavesArr = Array.isArray(waves) ? waves : Object.values(waves);
const newIdx = wavesArr.length + 1;
wavesArr.push({
title: `Волна ${newIdx}`,
isFlag: false,
zombies: [{ type: 'normal', count: 4, interval: 3.0 }]
});
this.selectedLevel.waves = wavesArr;
this.renderWavesConfigurator();
this.showToast(`Добавлена волна ${newIdx}`, 'info');
}
removeWave(waveIdx) {
this.collectCurrentLevelData();
let waves = this.selectedLevel.waves;
const wavesArr = Array.isArray(waves) ? waves : Object.values(waves);
if (wavesArr.length <= 1) {
this.showToast('Уровень должен содержать минимум 1 волну', 'warn');
return;
}
wavesArr.splice(waveIdx, 1);
this.selectedLevel.waves = wavesArr;
this.renderWavesConfigurator();
}
collectCurrentLevelData() {
if (!this.selectedLevel) return null;
const code = document.getElementById('level-code').value.trim() || '1-1';
const levelNum = parseInt(document.getElementById('level-number').value, 10) || 1;
const title = document.getElementById('level-title').value.trim() || `Уровень ${code}`;
const desc = document.getElementById('level-desc').value.trim();
const world = parseInt(document.getElementById('level-world').value, 10) || 1;
const biome = document.getElementById('level-biome').value;
const reward = parseInt(document.getElementById('level-reward-coins').value, 10) || 100;
const rewardCrystals = parseInt(document.getElementById('level-reward-crystals').value, 10) || 0;
const isBoss = document.getElementById('level-is-boss').checked ? 1 : 0;
// Modifiers
const modifiers = {
zombieHpMult: parseFloat(document.getElementById('mod-zombie-hp').value) || 1.0,
zombieSpeedMult: parseFloat(document.getElementById('mod-zombie-speed').value) || 1.0,
startingSun: parseInt(document.getElementById('mod-starting-sun').value, 10) || 50,
specialEvent: document.getElementById('mod-special-event').value || 'none',
conveyorDeck: document.getElementById('mod-conveyor-deck').checked,
fastRecharge: document.getElementById('mod-fast-recharge').checked
};
// Dialogue
const dialogue = {
speaker: document.getElementById('dialogue-speaker').value || '🧔',
title: document.getElementById('dialogue-title').value.trim(),
text: document.getElementById('dialogue-text').value.trim(),
btnText: document.getElementById('dialogue-btn-text').value.trim() || 'ДАЛЕЕ / В БОЙ ⚔️'
};
// Waves extraction from DOM
const waveCards = document.querySelectorAll('.wave-card');
const waves = [];
waveCards.forEach((card, wIdx) => {
const titleInput = card.querySelector('.wave-title-input');
const flagCheck = card.querySelector('.wave-flag-checkbox');
const zombieRows = card.querySelectorAll('.wave-zombie-row');
const zombies = [];
zombieRows.forEach(row => {
const type = row.querySelector('.wave-zombie-type').value;
const count = parseInt(row.querySelector('.wave-zombie-count').value, 10) || 1;
const interval = parseFloat(row.querySelector('.wave-zombie-interval').value) || 2.5;
zombies.push({ type, count, interval });
});
waves.push({
title: titleInput ? titleInput.value.trim() : `Волна ${wIdx + 1}`,
isFlag: flagCheck ? flagCheck.checked : false,
zombies: zombies.length > 0 ? zombies : [{ type: 'normal', count: 4, interval: 3.0 }]
});
});
const levelData = {
level: levelNum,
world: world,
stage: parseInt(code.split('-')[1], 10) || 1,
code: code,
title: title,
desc: desc,
reward: reward,
rewardCrystals: rewardCrystals,
biome: biome,
isBoss: isBoss,
waves: waves,
modifiers: modifiers,
dialogue: dialogue
};
this.selectedLevel = levelData;
return levelData;
}
async saveCurrentLevel() {
if (!this.isAuthenticated) {
this.showToast('Необходима авторизация администратора', 'warn');
return;
}
const levelData = this.collectCurrentLevelData();
if (!levelData) return;
const res = await this.apiRequest('/api/admin/campaign/level/save', 'POST', { level: levelData });
if (res.ok && res.data.success) {
this.showToast(`Уровень ${levelData.code} успешно сохранен!`, 'success');
// Update in local array
const idx = this.levels.findIndex(l => l.level === levelData.level);
if (idx !== -1) {
this.levels[idx] = levelData;
} else {
this.levels.push(levelData);
this.levels.sort((a,b) => a.level - b.level);
}
this.renderLevelSelector();
this.selectLevel(levelData.level);
} else {
this.showToast(res.data.message || 'Ошибка сохранения уровня', 'error');
}
}
async resetCurrentLevel() {
if (!this.isAuthenticated) {
this.showToast('Необходима авторизация администратора', 'warn');
return;
}
if (!this.selectedLevel) return;
if (!confirm(`Сбросить параметры уровня ${this.selectedLevel.code} к оригинальным заводским настройкам?`)) {
return;
}
const res = await this.apiRequest('/api/admin/campaign/level/reset', 'POST', { level: this.selectedLevel.level });
if (res.ok && res.data.success) {
this.showToast(`Уровень ${this.selectedLevel.code} сброшен к стандарту`, 'success');
await this.loadCampaignLevels();
} else {
this.showToast(res.data.message || 'Ошибка сброса уровня', 'error');
}
}
createNewLevel() {
const nextNum = (this.levels.reduce((max, l) => Math.max(max, l.level || 0), 0)) + 1;
const world = Math.min(5, Math.floor((nextNum - 1) / 5) + 1);
const stage = ((nextNum - 1) % 5) + 1;
const code = `${world}-${stage}`;
const newLvl = {
level: nextNum,
world: world,
stage: stage,
code: code,
title: `Новый рубеж ${code}`,
desc: `Пользовательский этап кампании ${code}`,
reward: 200,
rewardCrystals: 1,
biome: world === 2 ? 'night' : (world === 3 ? 'pool' : (world === 4 ? 'fog' : (world === 5 ? 'roof' : 'day'))),
isBoss: false,
waves: [
{ title: 'Волна 1', isFlag: false, zombies: [{ type: 'normal', count: 5, interval: 3.0 }] },
{ title: 'Финальная волна 🏁', isFlag: true, zombies: [{ type: 'flag', count: 1, interval: 1.0 }, { type: 'conehead', count: 3, interval: 2.5 }] }
],
modifiers: { zombieHpMult: 1.0, zombieSpeedMult: 1.0, startingSun: 50, specialEvent: 'none', conveyorDeck: false, fastRecharge: false },
dialogue: { speaker: '🧔', title: 'Безумный Дэйв готов к бою:', text: 'Новый уровень! Посади побольше подсолнухов!', btnText: 'ДАЛЕЕ / В БОЙ ⚔️' }
};
this.levels.push(newLvl);
this.renderLevelSelector();
this.selectLevel(nextNum);
this.showToast(`Создан новый этап ${code}. Настройте и нажмите "Сохранить".`, 'info');
}
// ==========================================
// ACTIVE PLAYER SESSIONS TAB
// ==========================================
async loadSessions() {
const res = await this.apiRequest('/api/admin/sessions');
if (res.ok && res.data.success) {
this.sessions = res.data.sessions || [];
const cnt = document.getElementById('sessions-total-count');
if (cnt) cnt.textContent = this.sessions.length;
const dashCnt = document.getElementById('dash-active-sessions');
if (dashCnt) dashCnt.textContent = this.sessions.length;
this.renderSessionsTable();
}
}
renderSessionsTable(filter = '') {
const tbody = document.getElementById('sessions-table-body');
if (!tbody) return;
filter = (filter || document.getElementById('sessions-search-input')?.value || '').toLowerCase().trim();
let filtered = this.sessions;
if (filter) {
filtered = filtered.filter(s =>
(s.username && s.username.toLowerCase().includes(filter)) ||
(s.ipAddress && s.ipAddress.toLowerCase().includes(filter)) ||
(s.token && s.token.toLowerCase().includes(filter)) ||
(s.deviceName && s.deviceName.toLowerCase().includes(filter))
);
}
if (filtered.length === 0) {
tbody.innerHTML = `| Активных сессий не найдено |
`;
return;
}
tbody.innerHTML = '';
filtered.forEach(s => {
const tr = document.createElement('tr');
const tokenShort = (s.token || '').substring(0, 10) + '...';
const username = s.username || 'Гость';
const ipAddress = s.ipAddress || '127.0.0.1';
const deviceName = (s.deviceName && s.deviceName !== 'Unknown Device') ? s.deviceName : 'Браузер';
const createdAt = s.createdAt || '—';
const lastActive = s.lastActive || s.createdAt || 'Только что';
tr.innerHTML = `
${username}
${s.isAdmin ? 'ADMIN' : ''}
|
${ipAddress} |
${deviceName} |
${createdAt} |
🟢 ${lastActive} |
${tokenShort} |
|
`;
tr.querySelector('.player-name-link').addEventListener('click', () => {
if (s.userId) this.openEditPlayerModal(s.userId);
});
tr.querySelector('.btn-terminate-session').addEventListener('click', () => {
this.terminateSession(s.token, s.username);
});
tbody.appendChild(tr);
});
}
async terminateSession(token, username = '') {
if (!this.isAuthenticated) {
this.showToast('Необходима авторизация администратора', 'warn');
return;
}
if (!confirm(`Принудительно завершить активную сессию для игрока ${username || token}?`)) {
return;
}
const res = await this.apiRequest('/api/admin/session/terminate', 'POST', { targetToken: token });
if (res.ok && res.data.success) {
this.showToast('Сессия успешно завершена', 'success');
await this.loadSessions();
await this.loadStats();
} else {
this.showToast(res.data.message || 'Ошибка завершения сессии', 'error');
}
}
// ==========================================
// LOGIN AUDIT LOGS TAB
// ==========================================
async loadLoginLogs() {
const res = await this.apiRequest('/api/admin/logs');
if (res.ok && res.data.success) {
this.loginLogs = res.data.logs || [];
const cnt = document.getElementById('logs-total-count');
if (cnt) cnt.textContent = this.loginLogs.length;
const dashCnt = document.getElementById('dash-total-logs');
if (dashCnt) dashCnt.textContent = this.loginLogs.length;
this.renderLoginLogsTable();
}
}
renderLoginLogsTable() {
const tbody = document.getElementById('logs-table-body');
if (!tbody) return;
const userFilter = document.getElementById('logs-filter-username')?.value.toLowerCase().trim() || '';
const ipFilter = document.getElementById('logs-filter-ip')?.value.toLowerCase().trim() || '';
const statusFilter = document.getElementById('logs-filter-status')?.value || 'all';
let filtered = this.loginLogs;
if (userFilter) {
filtered = filtered.filter(l => l.username && l.username.toLowerCase().includes(userFilter));
}
if (ipFilter) {
filtered = filtered.filter(l =>
(l.ipAddress && l.ipAddress.toLowerCase().includes(ipFilter)) ||
(l.ip && l.ip.toLowerCase().includes(ipFilter))
);
}
if (statusFilter !== 'all') {
filtered = filtered.filter(l => l.status === statusFilter);
}
if (filtered.length === 0) {
tbody.innerHTML = `| Записей в журнале входов не найдено |
`;
return;
}
tbody.innerHTML = '';
filtered.forEach(log => {
const tr = document.createElement('tr');
const timestamp = log.createdAt || log.timestamp || '—';
const username = log.username || 'Гость';
const ipAddress = log.ipAddress || log.ip || '127.0.0.1';
const deviceName = (log.deviceName && log.deviceName !== 'Unknown Device') ? log.deviceName : 'Браузер';
const status = log.status || 'unknown';
let statusBadge = `${status}`;
if (status === 'success') {
statusBadge = `✅ Успешно`;
} else if (status === 'registered') {
statusBadge = `🎉 Регистрация`;
} else if (status === 'wrong_password') {
statusBadge = `❌ Неверный пароль`;
} else if (status === 'banned_login_attempt') {
statusBadge = `🚫 Заблокирован`;
} else if (status === 'rate_limited') {
statusBadge = `⚠️ Превышен лимит`;
} else if (status === 'user_not_found') {
statusBadge = `❓ Не найден`;
}
tr.innerHTML = `
${timestamp} |
${username} |
${ipAddress} |
${deviceName} |
${statusBadge} |
`;
tbody.appendChild(tr);
});
}
// ==========================================
// CHANGE ADMIN PASSWORD ACTION
// ==========================================
async changeAdminPassword() {
const curPwd = document.getElementById('admin-current-password').value.trim();
const newPwd = document.getElementById('admin-new-password').value.trim();
const confirmPwd = document.getElementById('admin-confirm-password').value.trim();
const errDiv = document.getElementById('admin-password-error');
if (!newPwd) {
errDiv.textContent = 'Введите новый пароль';
errDiv.style.display = 'block';
return;
}
if (newPwd.length < 6) {
errDiv.textContent = 'Пароль должен содержать минимум 6 символов';
errDiv.style.display = 'block';
return;
}
if (newPwd !== confirmPwd) {
errDiv.textContent = 'Пароли не совпадают';
errDiv.style.display = 'block';
return;
}
errDiv.style.display = 'none';
const res = await this.apiRequest('/api/admin/password/change', 'POST', {
currentPassword: curPwd,
newPassword: newPwd
});
if (res.ok && res.data.success) {
this.showToast('Мастер-пароль администратора успешно изменен!', 'success');
this.closeModal('modal-admin-password');
document.getElementById('admin-current-password').value = '';
document.getElementById('admin-new-password').value = '';
document.getElementById('admin-confirm-password').value = '';
} else {
errDiv.textContent = res.data.message || 'Ошибка смены пароля';
errDiv.style.display = 'block';
}
}
// ==========================================
// PLAYERS TABLE & MODALS
// ==========================================
renderPlayersTable() {
const tbody = document.getElementById('players-table-body');
if (!tbody) return;
const search = document.getElementById('player-search-input')?.value.toLowerCase().trim() || '';
const filterStatus = document.getElementById('player-filter-status')?.value || 'all';
const sort = document.getElementById('player-sort-select')?.value || 'id_asc';
let list = [...this.users];
if (search) {
list = list.filter(u => u.username.toLowerCase().includes(search) || String(u.id).includes(search));
}
if (filterStatus === 'active') list = list.filter(u => !u.isBanned);
else if (filterStatus === 'banned') list = list.filter(u => u.isBanned);
else if (filterStatus === 'admin') list = list.filter(u => u.isAdmin);
list.sort((a, b) => {
if (sort === 'id_desc') return b.id - a.id;
if (sort === 'coins_desc') return b.coins - a.coins;
if (sort === 'level_desc') return b.level - a.level;
if (sort === 'login_desc') return (b.lastLogin || '').localeCompare(a.lastLogin || '');
return a.id - b.id;
});
if (list.length === 0) {
tbody.innerHTML = `| Игроки не найдены |
`;
return;
}
tbody.innerHTML = '';
list.forEach(u => {
const tr = document.createElement('tr');
let statusHtml = 'Активен';
if (u.isBanned) {
statusHtml = `🚫 Бан`;
} else if (u.isAdmin) {
statusHtml = '⭐ Админ';
}
tr.innerHTML = `
#${u.id} |
${u.username} |
${statusHtml} |
Ур. ${u.level} |
${(u.coins || 0).toLocaleString('ru-RU')} 💰 |
${(u.highScore || 0).toLocaleString('ru-RU')} |
${u.createdAt || '—'} |
${u.lastLogin || '—'} |
|
`;
tr.querySelector('.player-name-click').addEventListener('click', () => this.openPlayerStatsModal(u.id));
tr.querySelector('.btn-view-stats').addEventListener('click', () => this.openPlayerStatsModal(u.id));
tr.querySelector('.btn-edit-player').addEventListener('click', () => this.openEditPlayerModal(u.id));
tr.querySelector('.btn-reset-pwd-open').addEventListener('click', () => this.openResetPasswordModal(u.id, u.username));
tr.querySelector('.btn-delete-player').addEventListener('click', () => this.deletePlayer(u.id, u.username));
tbody.appendChild(tr);
});
}
async openPlayerStatsModal(userId) {
if (!this.isAuthenticated) {
this.showToast('Требуется авторизация администратора', 'warn');
return;
}
const res = await this.apiRequest(`/api/admin/user/${userId}`);
if (!res.ok || !res.data || !res.data.success) {
this.showToast('Ошибка загрузки статистики игрока', 'error');
return;
}
const u = res.data.user;
const prof = u.profile || u;
const st = prof.stats || u.stats || {};
const mats = prof.materials || u.materials || {};
const zen = prof.zenGarden || u.zenGarden || {};
const scores = u.scores || [];
const idInput = document.getElementById('stats-player-id');
if (idInput) idInput.value = u.id;
document.getElementById('stats-player-username').textContent = u.username || '—';
document.getElementById('stats-player-id-line').textContent = `#${u.id} (${prof.theme || 'classic'})`;
document.getElementById('stats-player-created').textContent = u.createdAt || '—';
document.getElementById('stats-player-login').textContent = u.lastLogin || '—';
let badge = 'Активен';
if (u.isBanned) badge = '🚫 Заблокирован';
else if (u.isAdmin) badge = '⭐ Администратор';
document.getElementById('stats-player-badge').innerHTML = badge;
// Lifetime Stats
document.getElementById('stats-zombies-killed').textContent = (st.zombiesKilled || 0).toLocaleString('ru-RU');
document.getElementById('stats-sun-produced').textContent = (st.sunProduced || 0).toLocaleString('ru-RU');
document.getElementById('stats-plants-planted').textContent = (st.plantsPlanted || 0).toLocaleString('ru-RU');
document.getElementById('stats-levels-cleared').textContent = `${st.levelsCompleted || 0} (макс. ${prof.maxUnlockedLevel || 1})`;
document.getElementById('stats-coins-earned').textContent = (st.coinsEarned || prof.coins || 0).toLocaleString('ru-RU');
// Resources
document.getElementById('stats-coins-val').textContent = (prof.coins ?? 150).toLocaleString('ru-RU');
document.getElementById('stats-biohumus-val').textContent = (mats.bioHumus || 0).toLocaleString('ru-RU');
document.getElementById('stats-scrap-val').textContent = (mats.scrapMetal || 0).toLocaleString('ru-RU');
document.getElementById('stats-essence-val').textContent = (mats.sunEssence || 0).toLocaleString('ru-RU');
document.getElementById('stats-crystals-val').textContent = (mats.crystals || 0).toLocaleString('ru-RU');
// Zen & Scores Summary
const potsCount = Array.isArray(zen.pots) ? zen.pots.filter(p => p && p.plantType).length : 0;
const snailState = zen.snail?.isAwake ? '🐌 Стинки бодрствует' : '🐌 Стинки спит';
document.getElementById('stats-zen-summary').innerHTML = `
Удобрений: ${zen.fertilizerCount ?? 0} | Шоколада: ${zen.chocolateCount ?? 0}
Растений: ${potsCount} шт. | ${snailState}
`;
let scoreTxt = `Кампания: ${(prof.highScore || 0).toLocaleString('ru-RU')}`;
if (scores.length > 0) {
scoreTxt += '
' + scores.slice(0, 2).map(s => `${s.mode}: ${s.score}`).join(' | ');
}
document.getElementById('stats-scores-summary').innerHTML = scoreTxt;
this.openModal('modal-player-stats');
}
async openEditPlayerModal(userId) {
if (!this.isAuthenticated) {
this.showToast('Требуется авторизация администратора', 'warn');
return;
}
const res = await this.apiRequest(`/api/admin/user/${userId}`);
if (!res.ok || !res.data || !res.data.success) {
this.showToast('Ошибка загрузки профиля игрока', 'error');
return;
}
const u = res.data.user;
const prof = u.profile || u;
const mats = prof.materials || u.materials || {};
const zen = prof.zenGarden || u.zenGarden || {};
document.getElementById('edit-player-id').value = u.id;
document.getElementById('edit-player-title-name').textContent = u.username || '';
document.getElementById('edit-player-coins').value = prof.coins ?? u.coins ?? 150;
document.getElementById('edit-player-level').value = prof.level ?? u.level ?? 1;
document.getElementById('edit-player-maxlevel').value = prof.maxUnlockedLevel ?? u.maxUnlockedLevel ?? 1;
document.getElementById('edit-player-highscore').value = prof.highScore ?? u.highScore ?? 0;
document.getElementById('edit-player-theme').value = prof.theme || u.theme || 'classic';
// Materials
document.getElementById('edit-player-mat-bioHumus').value = mats.bioHumus ?? 0;
document.getElementById('edit-player-mat-scrapMetal').value = mats.scrapMetal ?? 0;
document.getElementById('edit-player-mat-sunEssence').value = mats.sunEssence ?? 0;
document.getElementById('edit-player-mat-crystals').value = mats.crystals ?? 0;
// Zen Garden
document.getElementById('edit-player-zen-fertilizer').value = zen.fertilizerCount ?? 5;
document.getElementById('edit-player-zen-chocolate').value = zen.chocolateCount ?? 2;
document.getElementById('edit-player-is-admin').checked = !!u.isAdmin;
document.getElementById('edit-player-is-banned').checked = !!u.isBanned;
document.getElementById('edit-player-ban-reason').value = u.banReason || '';
const banCont = document.getElementById('edit-ban-reason-container');
if (banCont) banCont.style.display = u.isBanned ? 'block' : 'none';
this.openModal('modal-edit-player');
}
async savePlayerEdit() {
if (!this.isAuthenticated) {
this.showToast('Требуется авторизация администратора', 'warn');
return;
}
const id = document.getElementById('edit-player-id').value;
const updates = {
coins: parseInt(document.getElementById('edit-player-coins').value, 10) || 0,
level: parseInt(document.getElementById('edit-player-level').value, 10) || 1,
max_unlocked_level: parseInt(document.getElementById('edit-player-maxlevel').value, 10) || 1,
maxUnlockedLevel: parseInt(document.getElementById('edit-player-maxlevel').value, 10) || 1,
high_score: parseInt(document.getElementById('edit-player-highscore').value, 10) || 0,
highScore: parseInt(document.getElementById('edit-player-highscore').value, 10) || 0,
theme: document.getElementById('edit-player-theme').value,
materials: {
bioHumus: parseInt(document.getElementById('edit-player-mat-bioHumus').value, 10) || 0,
scrapMetal: parseInt(document.getElementById('edit-player-mat-scrapMetal').value, 10) || 0,
sunEssence: parseInt(document.getElementById('edit-player-mat-sunEssence').value, 10) || 0,
crystals: parseInt(document.getElementById('edit-player-mat-crystals').value, 10) || 0
},
zenGarden: {
fertilizerCount: parseInt(document.getElementById('edit-player-zen-fertilizer').value, 10) || 0,
chocolateCount: parseInt(document.getElementById('edit-player-zen-chocolate').value, 10) || 0
},
is_admin: document.getElementById('edit-player-is-admin').checked ? 1 : 0,
isAdmin: document.getElementById('edit-player-is-admin').checked,
is_banned: document.getElementById('edit-player-is-banned').checked ? 1 : 0,
isBanned: document.getElementById('edit-player-is-banned').checked,
ban_reason: document.getElementById('edit-player-ban-reason').value.trim(),
banReason: document.getElementById('edit-player-ban-reason').value.trim()
};
const res = await this.apiRequest(`/api/admin/user/${id}/edit`, 'POST', { updates });
if (res.ok && res.data && res.data.success) {
this.showToast('Профиль игрока и ресурсы успешно обновлены!', 'success');
this.closeModal('modal-edit-player');
this.loadUsers();
this.loadStats();
} else {
this.showToast((res.data && res.data.message) || 'Ошибка сохранения', 'error');
}
}
async createPlayer() {
if (!this.isAuthenticated) {
this.showToast('Требуется авторизация администратора', 'warn');
return;
}
const username = document.getElementById('create-player-username').value.trim();
const password = document.getElementById('create-player-password').value.trim();
const coins = parseInt(document.getElementById('create-player-coins').value, 10) || 150;
const isAdmin = document.getElementById('create-player-is-admin').checked;
if (!username || !password) {
this.showToast('Укажите имя пользователя и пароль', 'warn');
return;
}
const res = await this.apiRequest('/api/admin/user/create', 'POST', {
username,
password,
isAdmin,
initialData: { coins, level: 1, maxUnlockedLevel: 1 }
});
if (res.ok && res.data && res.data.success) {
this.showToast(`Игрок ${username} успешно создан!`, 'success');
this.closeModal('modal-create-player');
document.getElementById('create-player-username').value = '';
document.getElementById('create-player-password').value = '';
this.loadUsers();
this.loadStats();
} else {
this.showToast((res.data && res.data.message) || 'Ошибка создания игрока', 'error');
}
}
openResetPasswordModal(id, username) {
if (!this.isAuthenticated) {
this.showToast('Требуется авторизация администратора', 'warn');
return;
}
document.getElementById('reset-pwd-user-id').value = id;
document.getElementById('reset-pwd-username').textContent = username;
document.getElementById('reset-pwd-input').value = '';
this.openModal('modal-reset-password');
}
async submitResetPassword() {
if (!this.isAuthenticated) {
this.showToast('Требуется авторизация администратора', 'warn');
return;
}
const id = document.getElementById('reset-pwd-user-id').value;
const pwd = document.getElementById('reset-pwd-input').value.trim();
if (!pwd) {
this.showToast('Введите новый пароль', 'warn');
return;
}
const res = await this.apiRequest(`/api/admin/user/${id}/password`, 'POST', { password: pwd });
if (res.ok && res.data && res.data.success) {
this.showToast('Пароль успешно сброшен', 'success');
this.closeModal('modal-reset-password');
} else {
this.showToast((res.data && res.data.message) || 'Ошибка сброса пароля', 'error');
}
}
async deletePlayer(id, username) {
if (!confirm(`Вы действительно хотите удалить игрока "${username}" (#${id}) и все его данные безвозвратно?`)) {
return;
}
const res = await this.apiRequest(`/api/admin/user/${id}/delete`, 'POST');
if (res.ok && res.data.success) {
this.showToast(`Игрок ${username} удален`, 'success');
this.loadUsers();
this.loadStats();
this.loadSessions();
} else {
this.showToast(res.data.message || 'Ошибка удаления', 'error');
}
}
// ==========================================
// BUGS MODERATION
// ==========================================
renderBugsTable() {
const tbody = document.getElementById('bugs-table-body');
if (!tbody) return;
const filterStatus = document.getElementById('bug-filter-status')?.value || 'all';
const filterCat = document.getElementById('bug-filter-category')?.value || 'all';
let list = [...this.bugs];
if (filterStatus !== 'all') list = list.filter(b => b.status === filterStatus);
if (filterCat !== 'all') list = list.filter(b => b.category === filterCat);
const badge = document.getElementById('badge-bugs-count');
if (badge) {
const newCount = this.bugs.filter(b => b.status === 'новый').length;
badge.textContent = newCount;
badge.style.display = newCount > 0 ? 'inline-block' : 'none';
}
if (list.length === 0) {
tbody.innerHTML = `| Баг-репорты не найдены |
`;
return;
}
tbody.innerHTML = '';
list.forEach(b => {
const tr = document.createElement('tr');
let stBadge = `${b.status}`;
if (b.status === 'новый') stBadge = '🔴 Новый';
else if (b.status === 'в работе') stBadge = '🟡 В работе';
else if (b.status === 'исправлено') stBadge = '✅ Исправлено';
else if (b.status === 'отклонено') stBadge = '⚪ Отклонено';
tr.innerHTML = `
#${b.id} |
${b.timestamp || '—'} |
${b.author || 'Гость'} |
${b.category || 'bug'} |
${stBadge} |
${b.text} |
${b.adminReply || '—'} |
|
`;
tr.querySelector('.btn-moderate-bug').addEventListener('click', () => this.openModerateBugModal(b));
tbody.appendChild(tr);
});
}
openModerateBugModal(b) {
document.getElementById('bug-mod-id').textContent = b.id;
document.getElementById('bug-mod-meta').textContent = `Автор: ${b.author} | Категория: ${b.category} | Дата: ${b.timestamp}`;
document.getElementById('bug-mod-text').textContent = b.text;
document.getElementById('bug-mod-status').value = b.status || 'новый';
document.getElementById('bug-mod-reply').value = b.adminReply || '';
this.openModal('modal-moderate-bug');
}
async saveBugModeration() {
const id = parseInt(document.getElementById('bug-mod-id').textContent, 10);
const status = document.getElementById('bug-mod-status').value;
const adminReply = document.getElementById('bug-mod-reply').value.trim();
const res = await this.apiRequest(`/api/admin/bugs/${id}/status`, 'POST', { status, adminReply });
if (res.ok && res.data.success) {
this.showToast(`Баг #${id} обновлен`, 'success');
this.closeModal('modal-moderate-bug');
this.loadBugs();
} else {
this.showToast(res.data.message || 'Ошибка сохранения', 'error');
}
}
async deleteBug() {
const id = parseInt(document.getElementById('bug-mod-id').textContent, 10);
if (!confirm(`Удалить баг-репорт #${id}?`)) return;
const res = await this.apiRequest(`/api/admin/bugs/${id}/status`, 'POST', { delete: true });
if (res.ok && res.data.success) {
this.showToast(`Баг #${id} удален`, 'success');
this.closeModal('modal-moderate-bug');
this.loadBugs();
} else {
this.showToast(res.data.message || 'Ошибка удаления', 'error');
}
}
// ==========================================
// CONFIG PRESETS & BALANCE SAVE
// ==========================================
async applyPreset(presetName) {
if (!this.isAuthenticated) {
this.showToast('Необходима авторизация администратора', 'warn');
return;
}
const res = await this.apiRequest('/api/admin/config', 'POST', { applyPreset: presetName });
if (res.ok && res.data.success) {
this.showToast(`Пресет "${presetName}" успешно активирован на сервере!`, 'success');
this.config = res.data.config;
this.populateUIFromConfig();
this.loadStats();
} else {
this.showToast(res.data.message || 'Ошибка применения пресета', 'error');
}
}
async saveBalanceSection(sectionName) {
if (!this.isAuthenticated) {
this.showToast('Необходима авторизация администратора', 'warn');
return;
}
if (!this.config) this.config = {};
if (sectionName === 'economy') {
this.config.economy = {
sunMultiplier: parseFloat(document.getElementById('econ-sunMultiplier').value),
startingSun: parseInt(document.getElementById('econ-startingSun').value, 10),
coinDropRate: parseFloat(document.getElementById('econ-coinDropRate').value),
sunDropRate: parseFloat(document.getElementById('econ-sunDropRate').value),
plantCostMultiplier: parseFloat(document.getElementById('econ-plantCostMultiplier').value),
upgradeCostMultiplier: parseFloat(document.getElementById('econ-upgradeCostMultiplier').value)
};
} else if (sectionName === 'zombies') {
this.config.zombies = {
hpMultiplier: parseFloat(document.getElementById('zom-hpMultiplier').value),
speedMultiplier: parseFloat(document.getElementById('zom-speedMultiplier').value),
damageMultiplier: parseFloat(document.getElementById('zom-damageMultiplier').value),
waveDensityMultiplier: parseFloat(document.getElementById('zom-waveDensityMultiplier').value)
};
} else if (sectionName === 'plants') {
this.config.plants = {
damageMultiplier: parseFloat(document.getElementById('plant-damageMultiplier').value),
hpMultiplier: parseFloat(document.getElementById('plant-hpMultiplier').value),
cooldownMultiplier: parseFloat(document.getElementById('plant-cooldownMultiplier').value),
sunProduceIntervalMultiplier: parseFloat(document.getElementById('plant-sunProduceIntervalMultiplier').value)
};
} else if (sectionName === 'zenGarden') {
this.config.zenGarden = {
dropRateMultiplier: parseFloat(document.getElementById('zen-dropRateMultiplier').value),
growthRateMultiplier: parseFloat(document.getElementById('zen-growthRateMultiplier').value),
fertilizerMultiplier: parseFloat(document.getElementById('zen-fertilizerMultiplier').value),
waterIntervalMinutes: parseInt(document.getElementById('zen-waterIntervalMinutes').value, 10)
};
} else if (sectionName === 'events') {
this.config.events = {
doubleGold: document.getElementById('event-doubleGold').checked,
fastCooldowns: document.getElementById('event-fastCooldowns').checked,
infiniteSun: document.getElementById('event-infiniteSun').checked,
turboZombies: document.getElementById('event-turboZombies').checked,
activeEventName: 'custom'
};
}
this.config.preset = 'Кастомный (Custom)';
const res = await this.apiRequest('/api/admin/config', 'POST', { config: this.config });
if (res.ok && res.data.success) {
this.showToast(`Параметры раздела "${sectionName}" сохранены!`, 'success');
this.loadStats();
} else {
this.showToast(res.data.message || 'Ошибка сохранения', 'error');
}
}
// ==========================================
// BACKUPS EXPORT & IMPORT
// ==========================================
async exportBackup() {
if (!this.isAuthenticated) {
this.showToast('Необходима авторизация администратора', 'warn');
return;
}
const res = await this.apiRequest('/api/admin/backup/export');
if (res.ok && res.data.success) {
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(res.data.data, null, 2));
const downloadAnchor = document.createElement('a');
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
downloadAnchor.setAttribute("href", dataStr);
downloadAnchor.setAttribute("download", `pvz_full_backup_${timestamp}.json`);
document.body.appendChild(downloadAnchor);
downloadAnchor.click();
downloadAnchor.remove();
this.showToast('Резервная копия успешно выгружена!', 'success');
} else {
this.showToast(res.data.message || 'Ошибка выгрузки бэкапа', 'error');
}
}
async importBackup(file) {
if (!this.isAuthenticated) {
this.showToast('Необходима авторизация администратора', 'warn');
return;
}
const reader = new FileReader();
reader.onload = async (e) => {
try {
const jsonData = JSON.parse(e.target.result);
if (!confirm(`Вы действительно хотите восстановить базу данных из бэкапа "${file.name}"? Текущие данные будут перезаписаны!`)) {
return;
}
const res = await this.apiRequest('/api/admin/backup/import', 'POST', { data: jsonData });
if (res.ok && res.data.success) {
this.showToast('База данных успешно восстановлена!', 'success');
this.loadAllData();
} else {
this.showToast(res.data.message || 'Ошибка импорта бэкапа', 'error');
}
} catch (err) {
this.showToast('Неверный формат JSON файла бэкапа', 'error');
}
};
reader.readAsText(file);
}
// ==========================================
// EVENT BINDINGS
// ==========================================
bindEvents() {
// Navigation Tabs
document.querySelectorAll('.nav-tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.nav-tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
const target = btn.getAttribute('data-tab');
const pane = document.getElementById(target);
if (pane) pane.classList.add('active');
this.activeTab = target;
if (target === 'tab-players') this.loadUsers();
if (target === 'tab-sessions') this.loadSessions();
if (target === 'tab-logs') this.loadLoginLogs();
if (target === 'tab-levels' && this.levels.length === 0) this.loadCampaignLevels();
});
});
// Full-Screen Login Screen Bindings
const btnScreenSubmit = document.getElementById('btn-login-screen-submit');
if (btnScreenSubmit) {
btnScreenSubmit.addEventListener('click', (e) => {
e.preventDefault();
this.handleScreenLogin();
});
}
const screenPwdInput = document.getElementById('login-screen-password');
if (screenPwdInput) {
screenPwdInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.handleScreenLogin();
}
});
}
const btnTogglePwd = document.getElementById('btn-toggle-login-password');
if (btnTogglePwd && screenPwdInput) {
btnTogglePwd.addEventListener('click', () => {
if (screenPwdInput.type === 'password') {
screenPwdInput.type = 'text';
btnTogglePwd.textContent = '🙈';
} else {
screenPwdInput.type = 'password';
btnTogglePwd.textContent = '👁️';
}
});
}
// Login modal
const btnLoginOpen = document.getElementById('btn-login-modal-open');
if (btnLoginOpen) {
btnLoginOpen.addEventListener('click', () => {
const err = document.getElementById('login-error-msg');
if (err) err.style.display = 'none';
const input = document.getElementById('admin-password-input');
if (input) input.value = '';
this.openModal('modal-login');
});
}
const btnSubmitLogin = document.getElementById('btn-submit-login');
if (btnSubmitLogin) {
btnSubmitLogin.addEventListener('click', async () => {
const input = document.getElementById('admin-password-input');
const pwd = input ? input.value.trim() : '';
if (!pwd) return;
const res = await this.apiRequest('/api/admin/auth', 'POST', { password: pwd });
if (res.ok && res.data && res.data.success && res.data.token) {
this.token = res.data.token;
localStorage.setItem('pvz_admin_token', this.token);
this.closeModal('modal-login');
this.showToast('Авторизация администратора успешна!', 'success');
this.onAuthenticated();
} else {
const err = document.getElementById('login-error-msg');
if (err) {
err.textContent = (res.data && res.data.message) || 'Неверный пароль администратора';
err.style.display = 'block';
}
}
});
}
// Change Admin Password Modal
const btnOpenPwd = document.getElementById('btn-admin-password-modal-open');
if (btnOpenPwd) {
btnOpenPwd.addEventListener('click', () => {
const err = document.getElementById('admin-password-error');
if (err) err.style.display = 'none';
const cur = document.getElementById('admin-current-password');
if (cur) cur.value = '';
const nw = document.getElementById('admin-new-password');
if (nw) nw.value = '';
const cf = document.getElementById('admin-confirm-password');
if (cf) cf.value = '';
this.openModal('modal-admin-password');
});
}
const btnSubmitPwd = document.getElementById('btn-submit-change-admin-password');
if (btnSubmitPwd) {
btnSubmitPwd.addEventListener('click', () => this.changeAdminPassword());
}
// Logout
const btnLogout = document.getElementById('btn-admin-logout');
if (btnLogout) {
btnLogout.addEventListener('click', () => {
this.token = '';
localStorage.removeItem('pvz_admin_token');
this.onUnauthenticated();
this.showToast('Вы вышли из панели администратора', 'info');
});
}
// Close Modals
document.querySelectorAll('[data-close]').forEach(btn => {
btn.addEventListener('click', () => {
const modalId = btn.getAttribute('data-close');
this.closeModal(modalId);
});
});
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
overlay.classList.remove('active', 'open');
}
});
});
// Campaign Level Editor Actions
const btnSaveLevel = document.getElementById('btn-save-level');
if (btnSaveLevel) btnSaveLevel.addEventListener('click', () => this.saveCurrentLevel());
const btnResetLevel = document.getElementById('btn-reset-level');
if (btnResetLevel) btnResetLevel.addEventListener('click', () => this.resetCurrentLevel());
const btnReloadLevels = document.getElementById('btn-reload-levels');
if (btnReloadLevels) btnReloadLevels.addEventListener('click', () => this.loadCampaignLevels());
const btnCreateLvl = document.getElementById('btn-create-custom-level');
if (btnCreateLvl) btnCreateLvl.addEventListener('click', () => this.createNewLevel());
const btnAddWave = document.getElementById('btn-add-wave');
if (btnAddWave) btnAddWave.addEventListener('click', () => this.addWave());
// Dialogue Live Preview Binds
['dialogue-speaker', 'dialogue-title', 'dialogue-text', 'dialogue-btn-text'].forEach(id => {
const el = document.getElementById(id);
if (el) el.addEventListener('input', () => this.updateDialoguePreview());
});
// Sessions Actions
const btnRefSessions = document.getElementById('btn-refresh-sessions');
if (btnRefSessions) btnRefSessions.addEventListener('click', () => this.loadSessions());
const searchSessions = document.getElementById('sessions-search-input');
if (searchSessions) searchSessions.addEventListener('input', (e) => this.renderSessionsTable(e.target.value));
// Logs Actions
const btnRefLogs = document.getElementById('btn-refresh-logs');
if (btnRefLogs) btnRefLogs.addEventListener('click', () => this.loadLoginLogs());
['logs-filter-username', 'logs-filter-ip', 'logs-filter-status'].forEach(id => {
const el = document.getElementById(id);
if (el) el.addEventListener('input', () => this.renderLoginLogsTable());
if (el) el.addEventListener('change', () => this.renderLoginLogsTable());
});
// Player Actions
const btnCreatePlayerOpen = document.getElementById('btn-create-player-open');
if (btnCreatePlayerOpen) {
btnCreatePlayerOpen.addEventListener('click', () => {
this.openModal('modal-create-player');
});
}
const btnSubmitCreatePlayer = document.getElementById('btn-submit-create-player');
if (btnSubmitCreatePlayer) {
btnSubmitCreatePlayer.addEventListener('click', () => this.createPlayer());
}
const btnSavePlayerEdit = document.getElementById('btn-save-player-edit');
if (btnSavePlayerEdit) {
btnSavePlayerEdit.addEventListener('click', () => this.savePlayerEdit());
}
const btnSubmitResetPwd = document.getElementById('btn-submit-reset-pwd');
if (btnSubmitResetPwd) {
btnSubmitResetPwd.addEventListener('click', () => this.submitResetPassword());
}
const btnRefreshPlayers = document.getElementById('btn-refresh-players');
if (btnRefreshPlayers) {
btnRefreshPlayers.addEventListener('click', () => this.loadUsers());
}
const playerSearchInput = document.getElementById('player-search-input');
if (playerSearchInput) {
playerSearchInput.addEventListener('input', () => this.renderPlayersTable());
}
const playerFilterStatus = document.getElementById('player-filter-status');
if (playerFilterStatus) {
playerFilterStatus.addEventListener('change', () => this.renderPlayersTable());
}
const playerSortSelect = document.getElementById('player-sort-select');
if (playerSortSelect) {
playerSortSelect.addEventListener('change', () => this.renderPlayersTable());
}
const editPlayerIsBanned = document.getElementById('edit-player-is-banned');
if (editPlayerIsBanned) {
editPlayerIsBanned.addEventListener('change', (e) => {
const container = document.getElementById('edit-ban-reason-container');
if (container) container.style.display = e.target.checked ? 'block' : 'none';
});
}
const btnStatsJumpEdit = document.getElementById('btn-stats-jump-edit');
if (btnStatsJumpEdit) {
btnStatsJumpEdit.addEventListener('click', () => {
const id = document.getElementById('stats-player-id')?.value;
this.closeModal('modal-player-stats');
if (id) this.openEditPlayerModal(id);
});
}
// Balance Save Buttons
document.getElementById('btn-save-economy').addEventListener('click', () => this.saveBalanceSection('economy'));
document.getElementById('btn-save-zombies').addEventListener('click', () => this.saveBalanceSection('zombies'));
document.getElementById('btn-save-plants').addEventListener('click', () => this.saveBalanceSection('plants'));
document.getElementById('btn-save-zengarden').addEventListener('click', () => this.saveBalanceSection('zenGarden'));
document.getElementById('btn-save-events-quick').addEventListener('click', () => this.saveBalanceSection('events'));
// Presets
document.querySelectorAll('.btn-apply-preset').forEach(btn => {
btn.addEventListener('click', () => {
const preset = btn.getAttribute('data-preset');
this.applyPreset(preset);
});
});
// Bugs Actions
document.getElementById('btn-refresh-bugs').addEventListener('click', () => this.loadBugs());
document.getElementById('bug-filter-status').addEventListener('change', () => this.renderBugsTable());
document.getElementById('bug-filter-category').addEventListener('change', () => this.renderBugsTable());
document.getElementById('btn-bug-mod-save').addEventListener('click', () => this.saveBugModeration());
document.getElementById('btn-bug-delete').addEventListener('click', () => this.deleteBug());
// Backups Actions
document.getElementById('btn-export-backup').addEventListener('click', () => this.exportBackup());
document.getElementById('btn-trigger-import').addEventListener('click', () => {
document.getElementById('backup-file-input').click();
});
document.getElementById('backup-file-input').addEventListener('change', (e) => {
if (e.target.files && e.target.files[0]) {
this.importBackup(e.target.files[0]);
}
});
}
}
// Instantiate on DOM Load
window.addEventListener('DOMContentLoaded', () => {
window.pvzConfigurator = new PvZConfiguratorApp();
});