Files
ai-qpid-pvz/game/configurator/js/configurator.js
T

1602 lines
71 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 = `<span>${icon}</span> <span>${message}</span>`;
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 = `
<span class="level-pill-num">${lvl.code}</span>
<span class="level-pill-icon">${lvl.isBoss || lvl.is_boss ? '👑' : wIcon}</span>
`;
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 += `<option value="${t.id}" ${z.type === t.id ? 'selected' : ''}>${t.name}</option>`;
});
zombiesHtml += `
<div class="wave-zombie-row" data-zombie-index="${zIdx}">
<select class="form-select wave-zombie-type" style="padding: 4px 8px; font-size: 12px;">
${optHtml}
</select>
<div class="wave-zombie-count-group">
<span style="font-size: 11px; color: var(--text-muted);">Кол-во:</span>
<input type="number" class="form-input wave-zombie-count" min="1" max="50" value="${z.count || 1}" style="padding: 4px 6px; font-size: 12px; width: 55px;">
</div>
<div class="wave-zombie-interval-group">
<span style="font-size: 11px; color: var(--text-muted);">Интервал:</span>
<input type="number" class="form-input wave-zombie-interval" min="0.2" max="20" step="0.2" value="${z.interval || 2.5}" style="padding: 4px 6px; font-size: 12px; width: 60px;">
<span style="font-size: 10px; color: var(--text-dim);">с</span>
</div>
<button class="btn-remove-zombie-entry" title="Удалить тип зомби">🗑️</button>
</div>
`;
});
card.innerHTML = `
<div class="wave-card-header">
<div class="wave-card-title">
<span>${wave.isFlag ? '🏁' : '🌊'}</span>
<input type="text" class="form-input wave-title-input" value="${wave.title || 'Волна ' + (wIdx + 1)}" style="padding: 2px 8px; font-size: 13px; font-weight: bold; width: 160px; background: transparent; border-color: transparent;">
</div>
<div style="display: flex; align-items: center; gap: 10px;">
<label class="checkbox-label" style="font-size: 11px;">
<input type="checkbox" class="wave-flag-checkbox" ${wave.isFlag ? 'checked' : ''}>
<span>🚩 Флаг</span>
</label>
<button class="btn btn-secondary btn-sm btn-add-zombie-to-wave" style="padding: 2px 8px; font-size: 11px;">➕ Зомби</button>
<button class="btn btn-danger btn-sm btn-remove-wave" style="padding: 2px 8px; font-size: 11px;">✕</button>
</div>
</div>
<div class="wave-zombies-list">
${zombiesHtml}
</div>
`;
// 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 = `<tr><td colspan="7" style="text-align:center; color: var(--text-dim); padding: 24px;">Активных сессий не найдено</td></tr>`;
return;
}
tbody.innerHTML = '';
filtered.forEach(s => {
const tr = document.createElement('tr');
const tokenShort = (s.token || '').substring(0, 10) + '...';
const isOnline = true;
tr.innerHTML = `
<td>
<span class="player-name-link" data-user-id="${s.userId}">${s.username || 'Гость'}</span>
${s.isAdmin ? '<span class="badge badge-warning" style="font-size:10px; margin-left:4px;">ADMIN</span>' : ''}
</td>
<td><span style="font-family:monospace; color:var(--text-muted);">${s.ipAddress || '127.0.0.1'}</span></td>
<td><span style="color:var(--text-main); font-size:12px;">${s.deviceName || 'Браузер'}</span></td>
<td style="color:var(--text-dim); font-size:12px;">${s.createdAt || '—'}</td>
<td><span style="color:#a5d6a7; font-size:12px;">🟢 ${s.lastActive || s.createdAt || 'Только что'}</span></td>
<td><span class="token-cell" title="${s.token}">${tokenShort}</span></td>
<td>
<button class="btn btn-danger btn-sm btn-terminate-session" data-token="${s.token}" title="Завершить сессию игрока">
🚪 Завершить
</button>
</td>
`;
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 = `<tr><td colspan="5" style="text-align:center; color: var(--text-dim); padding: 24px;">Записей в журнале входов не найдено</td></tr>`;
return;
}
tbody.innerHTML = '';
filtered.forEach(log => {
const tr = document.createElement('tr');
let statusBadge = `<span class="badge badge-muted">${log.status}</span>`;
if (log.status === 'success') {
statusBadge = `<span class="badge badge-success">✅ Успешно</span>`;
} else if (log.status === 'registered') {
statusBadge = `<span class="badge badge-purple">🎉 Регистрация</span>`;
} else if (log.status === 'wrong_password') {
statusBadge = `<span class="badge badge-danger">❌ Неверный пароль</span>`;
} else if (log.status === 'banned_login_attempt') {
statusBadge = `<span class="badge badge-warning">🚫 Заблокирован</span>`;
} else if (log.status === 'rate_limited') {
statusBadge = `<span class="badge badge-warning">⚠️ Превышен лимит</span>`;
} else if (log.status === 'user_not_found') {
statusBadge = `<span class="badge badge-muted">❓ Не найден</span>`;
}
tr.innerHTML = `
<td style="color:var(--text-dim); font-size:12px; font-family:monospace;">${log.timestamp || '—'}</td>
<td><b style="color:var(--accent-cyan);">${log.username || '—'}</b></td>
<td><span style="font-family:monospace; color:var(--text-muted); font-size:12px;">${log.ipAddress || '—'}</span></td>
<td style="font-size:12px; color:var(--text-main);">${log.deviceName || 'Браузер'}</td>
<td>${statusBadge}</td>
`;
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 = `<tr><td colspan="9" style="text-align:center; color: var(--text-dim); padding: 24px;">Игроки не найдены</td></tr>`;
return;
}
tbody.innerHTML = '';
list.forEach(u => {
const tr = document.createElement('tr');
let statusHtml = '<span class="badge badge-success">Активен</span>';
if (u.isBanned) {
statusHtml = `<span class="badge badge-danger" title="${u.banReason || 'Заблокирован'}">🚫 Бан</span>`;
} else if (u.isAdmin) {
statusHtml = '<span class="badge badge-warning">⭐ Админ</span>';
}
tr.innerHTML = `
<td><span style="font-family:monospace; color:var(--text-dim);">#${u.id}</span></td>
<td><b style="color:var(--accent-cyan); cursor:pointer;" class="player-name-click">${u.username}</b></td>
<td>${statusHtml}</td>
<td><span class="badge badge-info">Ур. ${u.level}</span></td>
<td><span style="color:var(--accent-gold); font-weight:bold;">${(u.coins || 0).toLocaleString('ru-RU')} 💰</span></td>
<td><span style="color:var(--text-main); font-weight:600;">${(u.highScore || 0).toLocaleString('ru-RU')}</span></td>
<td style="font-size:12px; color:var(--text-dim);">${u.createdAt || '—'}</td>
<td style="font-size:12px; color:var(--text-dim);">${u.lastLogin || '—'}</td>
<td>
<div style="display: flex; gap: 6px;">
<button class="btn btn-secondary btn-sm btn-view-stats" title="Статистика и инвентарь">📊</button>
<button class="btn btn-secondary btn-sm btn-edit-player" title="Редактировать">✏️</button>
<button class="btn btn-secondary btn-sm btn-reset-pwd-open" title="Сбросить пароль">🔑</button>
<button class="btn btn-danger btn-sm btn-delete-player" title="Удалить аккаунт">🗑️</button>
</div>
</td>
`;
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 = '<span class="badge badge-success">Активен</span>';
if (u.isBanned) badge = '<span class="badge badge-danger">🚫 Заблокирован</span>';
else if (u.isAdmin) badge = '<span class="badge badge-warning">⭐ Администратор</span>';
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 = `
Удобрений: <b>${zen.fertilizerCount ?? 0}</b> | Шоколада: <b>${zen.chocolateCount ?? 0}</b><br>
Растений: <b>${potsCount}</b> шт. | ${snailState}
`;
let scoreTxt = `Кампания: <b>${(prof.highScore || 0).toLocaleString('ru-RU')}</b>`;
if (scores.length > 0) {
scoreTxt += '<br>' + scores.slice(0, 2).map(s => `${s.mode}: <b>${s.score}</b>`).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 = `<tr><td colspan="8" style="text-align:center; color: var(--text-dim); padding: 24px;">Баг-репорты не найдены</td></tr>`;
return;
}
tbody.innerHTML = '';
list.forEach(b => {
const tr = document.createElement('tr');
let stBadge = `<span class="badge badge-muted">${b.status}</span>`;
if (b.status === 'новый') stBadge = '<span class="badge badge-danger">🔴 Новый</span>';
else if (b.status === 'в работе') stBadge = '<span class="badge badge-warning">🟡 В работе</span>';
else if (b.status === 'исправлено') stBadge = '<span class="badge badge-success">✅ Исправлено</span>';
else if (b.status === 'отклонено') stBadge = '<span class="badge badge-muted">⚪ Отклонено</span>';
tr.innerHTML = `
<td><span style="font-family:monospace;">#${b.id}</span></td>
<td style="font-size:12px; color:var(--text-dim);">${b.timestamp || '—'}</td>
<td><b style="color:var(--text-main);">${b.author || 'Гость'}</b></td>
<td><span class="badge badge-info">${b.category || 'bug'}</span></td>
<td>${stBadge}</td>
<td style="max-width:280px; font-size:12px;">${b.text}</td>
<td style="max-width:220px; font-size:12px; color:#a5d6a7;">${b.adminReply || '—'}</td>
<td>
<button class="btn btn-secondary btn-sm btn-moderate-bug">🛠️ Ответ</button>
</td>
`;
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();
});