/** * 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(); this.loadPublicConfig(); } } else { this.onUnauthenticated(); this.loadPublicConfig(); } } // ========================================== // 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; document.getElementById('admin-status-dot').className = 'status-dot'; document.getElementById('admin-status-text').textContent = 'Администратор'; document.getElementById('btn-login-modal-open').style.display = 'none'; const btnPwd = document.getElementById('btn-admin-password-modal-open'); if (btnPwd) btnPwd.style.display = 'inline-flex'; document.getElementById('btn-admin-logout').style.display = 'inline-flex'; this.loadAllData(); } onUnauthenticated() { this.isAuthenticated = false; document.getElementById('admin-status-dot').className = 'status-dot offline'; document.getElementById('admin-status-text').textContent = 'Гость (Только чтение)'; document.getElementById('btn-login-modal-open').style.display = 'inline-flex'; const btnPwd = document.getElementById('btn-admin-password-modal-open'); if (btnPwd) btnPwd.style.display = 'none'; document.getElementById('btn-admin-logout').style.display = 'none'; } 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 = `
${wave.isFlag ? '🏁' : '🌊'}
${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 isOnline = true; tr.innerHTML = ` ${s.username || 'Гость'} ${s.isAdmin ? 'ADMIN' : ''} ${s.ipAddress || '127.0.0.1'} ${s.deviceName || 'Браузер'} ${s.createdAt || '—'} 🟢 ${s.lastActive || s.createdAt || 'Только что'} ${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)); } 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'); let statusBadge = `${log.status}`; if (log.status === 'success') { statusBadge = `✅ Успешно`; } else if (log.status === 'registered') { statusBadge = `🎉 Регистрация`; } else if (log.status === 'wrong_password') { statusBadge = `❌ Неверный пароль`; } else if (log.status === 'banned_login_attempt') { statusBadge = `🚫 Заблокирован`; } else if (log.status === 'rate_limited') { statusBadge = `⚠️ Превышен лимит`; } else if (log.status === 'user_not_found') { statusBadge = `❓ Не найден`; } tr.innerHTML = ` ${log.timestamp || '—'} ${log.username || '—'} ${log.ipAddress || '—'} ${log.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'); document.getElementById('modal-admin-password').classList.remove('active'); 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) { const res = await this.apiRequest(`/api/admin/user/${userId}`); if (!res.ok || !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; document.getElementById('modal-player-stats').classList.add('active'); } async openEditPlayerModal(userId) { const res = await this.apiRequest(`/api/admin/user/${userId}`); if (!res.ok || !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'; document.getElementById('modal-edit-player').classList.add('active'); } async savePlayerEdit() { 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.success) { this.showToast('Профиль игрока и ресурсы успешно обновлены!', 'success'); document.getElementById('modal-edit-player').classList.remove('active'); this.loadUsers(); this.loadStats(); } else { this.showToast(res.data.message || 'Ошибка сохранения', 'error'); } } async createPlayer() { 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.success) { this.showToast(`Игрок ${username} создан!`, 'success'); document.getElementById('modal-create-player').classList.remove('active'); document.getElementById('create-player-username').value = ''; document.getElementById('create-player-password').value = ''; this.loadUsers(); this.loadStats(); } else { this.showToast(res.data.message || 'Ошибка создания игрока', 'error'); } } openResetPasswordModal(id, username) { document.getElementById('reset-pwd-user-id').value = id; document.getElementById('reset-pwd-username').textContent = username; document.getElementById('reset-pwd-input').value = ''; document.getElementById('modal-reset-password').classList.add('active'); } async submitResetPassword() { 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.success) { this.showToast('Пароль успешно сброшен', 'success'); document.getElementById('modal-reset-password').classList.remove('active'); } else { this.showToast(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 || ''; document.getElementById('modal-moderate-bug').classList.add('active'); } 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'); document.getElementById('modal-moderate-bug').classList.remove('active'); 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'); document.getElementById('modal-moderate-bug').classList.remove('active'); 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-levels' && this.levels.length === 0) this.loadCampaignLevels(); if (target === 'tab-sessions') this.loadSessions(); if (target === 'tab-logs') this.loadLoginLogs(); }); }); // Login modal document.getElementById('btn-login-modal-open').addEventListener('click', () => { document.getElementById('login-error-msg').style.display = 'none'; document.getElementById('admin-password-input').value = ''; document.getElementById('modal-login').classList.add('active'); }); document.getElementById('btn-submit-login').addEventListener('click', async () => { const pwd = document.getElementById('admin-password-input').value.trim(); if (!pwd) return; const res = await this.apiRequest('/api/admin/auth', 'POST', { password: pwd }); if (res.ok && res.data.success) { this.token = res.data.token; localStorage.setItem('pvz_admin_token', this.token); this.onAuthenticated(); document.getElementById('modal-login').classList.remove('active'); this.showToast('Авторизация администратора успешна!', 'success'); } else { const err = document.getElementById('login-error-msg'); err.textContent = 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', () => { document.getElementById('admin-password-error').style.display = 'none'; document.getElementById('admin-current-password').value = ''; document.getElementById('admin-new-password').value = ''; document.getElementById('admin-confirm-password').value = ''; document.getElementById('modal-admin-password').classList.add('active'); }); } const btnSubmitPwd = document.getElementById('btn-submit-change-admin-password'); if (btnSubmitPwd) { btnSubmitPwd.addEventListener('click', () => this.changeAdminPassword()); } // Logout document.getElementById('btn-admin-logout').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'); const modal = document.getElementById(modalId); if (modal) modal.classList.remove('active'); }); }); // 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 document.getElementById('btn-create-player-open').addEventListener('click', () => { document.getElementById('modal-create-player').classList.add('active'); }); document.getElementById('btn-submit-create-player').addEventListener('click', () => this.createPlayer()); document.getElementById('btn-save-player-edit').addEventListener('click', () => this.savePlayerEdit()); document.getElementById('btn-submit-reset-pwd').addEventListener('click', () => this.submitResetPassword()); document.getElementById('btn-refresh-players').addEventListener('click', () => this.loadUsers()); document.getElementById('player-search-input').addEventListener('input', () => this.renderPlayersTable()); document.getElementById('player-filter-status').addEventListener('change', () => this.renderPlayersTable()); document.getElementById('player-sort-select').addEventListener('change', () => this.renderPlayersTable()); document.getElementById('edit-player-is-banned').addEventListener('change', (e) => { document.getElementById('edit-ban-reason-container').style.display = e.target.checked ? 'block' : 'none'; }); document.getElementById('btn-stats-jump-edit')?.addEventListener('click', () => { const id = document.getElementById('stats-player-id')?.value; const modal = document.getElementById('modal-player-stats'); if (modal) modal.classList.remove('active'); 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(); });