1822 lines
78 KiB
JavaScript
1822 lines
78 KiB
JavaScript
/**
|
||
* Plants vs. Zombies — Admin Panel & Game Configurator Client
|
||
* Handles Admin Authentication, REST API communication, Sliders, Live Events,
|
||
* User Management, Presets, Bug Moderation, Campaign Level Editor,
|
||
* Active Player Sessions, Login Audit Logs, and Backups.
|
||
*/
|
||
|
||
class PvZConfiguratorApp {
|
||
constructor() {
|
||
this.token = localStorage.getItem('pvz_admin_token') || '';
|
||
this.isAuthenticated = false;
|
||
this.config = null;
|
||
this.stats = null;
|
||
this.users = [];
|
||
this.bugs = [];
|
||
this.levels = [];
|
||
this.selectedLevel = null;
|
||
this.sessions = [];
|
||
this.loginLogs = [];
|
||
this.activeTab = 'tab-dashboard';
|
||
|
||
this.init();
|
||
}
|
||
|
||
async init() {
|
||
this.bindEvents();
|
||
this.setupSliders();
|
||
|
||
// Check initial auth status
|
||
if (this.token) {
|
||
const valid = await this.verifyAuth();
|
||
if (valid) {
|
||
this.onAuthenticated();
|
||
} else {
|
||
this.onUnauthenticated();
|
||
}
|
||
} else {
|
||
this.onUnauthenticated();
|
||
}
|
||
}
|
||
|
||
// ==========================================
|
||
// Toast Alerts
|
||
// ==========================================
|
||
showToast(message, type = 'success') {
|
||
const container = document.getElementById('toast-container');
|
||
if (!container) return;
|
||
|
||
const toast = document.createElement('div');
|
||
toast.className = `toast ${type}`;
|
||
const icon = type === 'success' ? '✅' : (type === 'error' ? '❌' : (type === 'warn' ? '⚠️' : 'ℹ️'));
|
||
toast.innerHTML = `<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;
|
||
|
||
const loginScreen = document.getElementById('configurator-login-screen');
|
||
if (loginScreen) loginScreen.style.display = 'none';
|
||
|
||
const appContainer = document.getElementById('configurator-app-container');
|
||
if (appContainer) appContainer.style.display = 'block';
|
||
|
||
const dot = document.getElementById('admin-status-dot');
|
||
if (dot) dot.className = 'status-dot';
|
||
const stText = document.getElementById('admin-status-text');
|
||
if (stText) stText.textContent = 'Администратор';
|
||
|
||
const btnLoginModal = document.getElementById('btn-login-modal-open');
|
||
if (btnLoginModal) btnLoginModal.style.display = 'none';
|
||
|
||
const btnPwd = document.getElementById('btn-admin-password-modal-open');
|
||
if (btnPwd) btnPwd.style.display = 'inline-flex';
|
||
|
||
const btnLogout = document.getElementById('btn-admin-logout');
|
||
if (btnLogout) btnLogout.style.display = 'inline-flex';
|
||
|
||
this.loadAllData();
|
||
}
|
||
|
||
onUnauthenticated() {
|
||
this.isAuthenticated = false;
|
||
this.token = '';
|
||
localStorage.removeItem('pvz_admin_token');
|
||
this.config = null;
|
||
this.stats = null;
|
||
this.users = [];
|
||
this.sessions = [];
|
||
this.loginLogs = [];
|
||
this.bugs = [];
|
||
this.levels = [];
|
||
|
||
const appContainer = document.getElementById('configurator-app-container');
|
||
if (appContainer) appContainer.style.display = 'none';
|
||
|
||
const loginScreen = document.getElementById('configurator-login-screen');
|
||
if (loginScreen) loginScreen.style.display = 'flex';
|
||
|
||
const loginErr = document.getElementById('login-screen-error');
|
||
if (loginErr) {
|
||
loginErr.style.display = 'none';
|
||
loginErr.textContent = '';
|
||
}
|
||
|
||
const pwdInput = document.getElementById('login-screen-password');
|
||
if (pwdInput) pwdInput.value = '';
|
||
|
||
const dot = document.getElementById('admin-status-dot');
|
||
if (dot) dot.className = 'status-dot offline';
|
||
const stText = document.getElementById('admin-status-text');
|
||
if (stText) stText.textContent = 'Гость (Только чтение)';
|
||
|
||
const btnLoginModal = document.getElementById('btn-login-modal-open');
|
||
if (btnLoginModal) btnLoginModal.style.display = 'inline-flex';
|
||
|
||
const btnPwd = document.getElementById('btn-admin-password-modal-open');
|
||
if (btnPwd) btnPwd.style.display = 'none';
|
||
|
||
const btnLogout = document.getElementById('btn-admin-logout');
|
||
if (btnLogout) btnLogout.style.display = 'none';
|
||
}
|
||
|
||
openModal(modalId) {
|
||
const modal = document.getElementById(modalId);
|
||
if (modal) {
|
||
modal.classList.add('active', 'open');
|
||
}
|
||
}
|
||
|
||
closeModal(modalId) {
|
||
const modal = document.getElementById(modalId);
|
||
if (modal) {
|
||
modal.classList.remove('active', 'open');
|
||
}
|
||
}
|
||
|
||
async handleScreenLogin() {
|
||
const pwdInput = document.getElementById('login-screen-password');
|
||
const errDiv = document.getElementById('login-screen-error');
|
||
const enteredPassword = pwdInput ? pwdInput.value.trim() : '';
|
||
|
||
if (!enteredPassword) {
|
||
if (errDiv) {
|
||
errDiv.textContent = 'Введите мастер-пароль администратора';
|
||
errDiv.style.display = 'flex';
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (errDiv) {
|
||
errDiv.style.display = 'none';
|
||
errDiv.textContent = '';
|
||
}
|
||
|
||
const res = await this.apiRequest('/api/admin/auth', 'POST', { password: enteredPassword });
|
||
if (res.ok && res.data && res.data.success && res.data.token) {
|
||
this.token = res.data.token;
|
||
localStorage.setItem('pvz_admin_token', this.token);
|
||
this.showToast('Авторизация успешна! Добро пожаловать в панель управления.', 'success');
|
||
this.onAuthenticated();
|
||
} else {
|
||
if (errDiv) {
|
||
errDiv.textContent = (res.data && res.data.message) || 'Неверный пароль администратора';
|
||
errDiv.style.display = 'flex';
|
||
}
|
||
}
|
||
}
|
||
|
||
async loadPublicConfig() {
|
||
const res = await this.apiRequest('/api/config');
|
||
if (res.ok) {
|
||
this.config = res.data;
|
||
this.populateUIFromConfig();
|
||
}
|
||
}
|
||
|
||
async loadAllData() {
|
||
await Promise.all([
|
||
this.loadStats(),
|
||
this.loadConfig(),
|
||
this.loadUsers(),
|
||
this.loadBugs(),
|
||
this.loadCampaignLevels(),
|
||
this.loadSessions(),
|
||
this.loadLoginLogs()
|
||
]);
|
||
}
|
||
|
||
// ==========================================
|
||
// Data Fetching
|
||
// ==========================================
|
||
async loadStats() {
|
||
const res = await this.apiRequest('/api/admin/stats');
|
||
if (res.ok && res.data.success) {
|
||
this.stats = res.data.stats;
|
||
this.renderDashboard();
|
||
}
|
||
}
|
||
|
||
async loadConfig() {
|
||
const res = await this.apiRequest('/api/admin/config');
|
||
if (res.ok && res.data.success) {
|
||
this.config = res.data.config;
|
||
this.populateUIFromConfig();
|
||
}
|
||
}
|
||
|
||
async loadUsers() {
|
||
const res = await this.apiRequest('/api/admin/users');
|
||
if (res.ok && res.data.success) {
|
||
this.users = res.data.users || [];
|
||
this.renderPlayersTable();
|
||
}
|
||
}
|
||
|
||
async loadBugs() {
|
||
const res = await this.apiRequest('/api/admin/bugs');
|
||
if (res.ok && res.data.success) {
|
||
this.bugs = res.data.bugs || [];
|
||
this.renderBugsTable();
|
||
}
|
||
}
|
||
|
||
// ==========================================
|
||
// UI Rendering & Sliders
|
||
// ==========================================
|
||
setupSliders() {
|
||
document.querySelectorAll('.range-slider').forEach(slider => {
|
||
slider.addEventListener('input', (e) => {
|
||
const id = e.target.id;
|
||
const val = parseFloat(e.target.value);
|
||
const badge = document.getElementById(`val-${id}`);
|
||
if (badge) {
|
||
if (id === 'econ-startingSun') {
|
||
badge.textContent = `${val} ☀️`;
|
||
} else if (id === 'zen-waterIntervalMinutes') {
|
||
badge.textContent = `${val} мин`;
|
||
} else {
|
||
badge.textContent = `${val.toFixed(1)}x`;
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
setSliderValue(id, value, suffix = 'x') {
|
||
const el = document.getElementById(id);
|
||
const badge = document.getElementById(`val-${id}`);
|
||
if (el) el.value = value;
|
||
if (badge) {
|
||
if (suffix === 'sun') {
|
||
badge.textContent = `${value} ☀️`;
|
||
} else if (suffix === 'min') {
|
||
badge.textContent = `${value} мин`;
|
||
} else {
|
||
badge.textContent = `${parseFloat(value).toFixed(1)}x`;
|
||
}
|
||
}
|
||
}
|
||
|
||
populateUIFromConfig() {
|
||
if (!this.config) return;
|
||
|
||
// 1. Economy
|
||
const eco = this.config.economy || {};
|
||
this.setSliderValue('econ-sunMultiplier', eco.sunMultiplier ?? 1.0);
|
||
this.setSliderValue('econ-startingSun', eco.startingSun ?? 50, 'sun');
|
||
this.setSliderValue('econ-coinDropRate', eco.coinDropRate ?? 1.0);
|
||
this.setSliderValue('econ-sunDropRate', eco.sunDropRate ?? 1.0);
|
||
this.setSliderValue('econ-plantCostMultiplier', eco.plantCostMultiplier ?? 1.0);
|
||
this.setSliderValue('econ-upgradeCostMultiplier', eco.upgradeCostMultiplier ?? 1.0);
|
||
|
||
// 2. Zombies
|
||
const zom = this.config.zombies || {};
|
||
this.setSliderValue('zom-hpMultiplier', zom.hpMultiplier ?? 1.0);
|
||
this.setSliderValue('zom-speedMultiplier', zom.speedMultiplier ?? 1.0);
|
||
this.setSliderValue('zom-damageMultiplier', zom.damageMultiplier ?? 1.0);
|
||
this.setSliderValue('zom-waveDensityMultiplier', zom.waveDensityMultiplier ?? 1.0);
|
||
|
||
// 3. Plants
|
||
const plt = this.config.plants || {};
|
||
this.setSliderValue('plant-damageMultiplier', plt.damageMultiplier ?? 1.0);
|
||
this.setSliderValue('plant-hpMultiplier', plt.hpMultiplier ?? 1.0);
|
||
this.setSliderValue('plant-cooldownMultiplier', plt.cooldownMultiplier ?? 1.0);
|
||
this.setSliderValue('plant-sunProduceIntervalMultiplier', plt.sunProduceIntervalMultiplier ?? 1.0);
|
||
|
||
// 4. Zen Garden
|
||
const zen = this.config.zenGarden || {};
|
||
this.setSliderValue('zen-dropRateMultiplier', zen.dropRateMultiplier ?? 1.0);
|
||
this.setSliderValue('zen-growthRateMultiplier', zen.growthRateMultiplier ?? 1.0);
|
||
this.setSliderValue('zen-fertilizerMultiplier', zen.fertilizerMultiplier ?? 1.0);
|
||
this.setSliderValue('zen-waterIntervalMinutes', zen.waterIntervalMinutes ?? 30, 'min');
|
||
|
||
// 5. Events
|
||
const ev = this.config.events || {};
|
||
document.getElementById('event-doubleGold').checked = !!ev.doubleGold;
|
||
document.getElementById('event-fastCooldowns').checked = !!ev.fastCooldowns;
|
||
document.getElementById('event-infiniteSun').checked = !!ev.infiniteSun;
|
||
document.getElementById('event-turboZombies').checked = !!ev.turboZombies;
|
||
}
|
||
|
||
renderDashboard() {
|
||
if (!this.stats) return;
|
||
document.getElementById('dash-total-users').textContent = this.stats.totalUsers ?? 0;
|
||
document.getElementById('dash-active-24h').textContent = this.stats.active24h ?? 0;
|
||
document.getElementById('dash-total-coins').textContent = (this.stats.totalCoins ?? 0).toLocaleString('ru-RU');
|
||
document.getElementById('dash-banned-users').textContent = this.stats.bannedUsers ?? 0;
|
||
document.getElementById('dash-total-bugs').textContent = this.stats.totalBugs ?? (this.bugs ? this.bugs.length : 0);
|
||
document.getElementById('dash-active-preset').textContent = this.stats.activeConfig?.preset || this.config?.preset || 'Classic 2009';
|
||
|
||
const sCount = document.getElementById('dash-active-sessions');
|
||
if (sCount) sCount.textContent = this.stats.activeSessions ?? (this.sessions ? this.sessions.length : 0);
|
||
|
||
const lCount = document.getElementById('dash-total-logs');
|
||
if (lCount) lCount.textContent = this.stats.totalLoginLogs ?? (this.loginLogs ? this.loginLogs.length : 0);
|
||
|
||
const cCount = document.getElementById('dash-campaign-levels');
|
||
if (cCount) cCount.textContent = this.stats.campaignLevelsCount ?? (this.levels ? this.levels.length : 25);
|
||
|
||
const zCount = document.getElementById('dash-total-zombies');
|
||
if (zCount) zCount.textContent = (this.stats.totalZombiesKilled ?? 0).toLocaleString('ru-RU');
|
||
|
||
const pCount = document.getElementById('dash-total-plants');
|
||
if (pCount) pCount.textContent = (this.stats.totalPlantsPlanted ?? 0).toLocaleString('ru-RU');
|
||
|
||
const sunCount = document.getElementById('dash-total-sun');
|
||
if (sunCount) sunCount.textContent = (this.stats.totalSunProduced ?? 0).toLocaleString('ru-RU');
|
||
}
|
||
|
||
// ==========================================
|
||
// CAMPAIGN LEVEL EDITOR TAB
|
||
// ==========================================
|
||
async loadCampaignLevels() {
|
||
const res = await this.apiRequest('/api/admin/campaign/levels');
|
||
if (res.ok && res.data.success) {
|
||
this.levels = res.data.levels || [];
|
||
this.renderLevelSelector();
|
||
if (!this.selectedLevel && this.levels.length > 0) {
|
||
this.selectLevel(1);
|
||
} else if (this.selectedLevel) {
|
||
this.selectLevel(this.selectedLevel.level);
|
||
}
|
||
}
|
||
}
|
||
|
||
renderLevelSelector() {
|
||
const dropdown = document.getElementById('level-select-dropdown');
|
||
const grid = document.getElementById('levels-grid-selector');
|
||
if (!dropdown || !grid) return;
|
||
|
||
dropdown.innerHTML = '';
|
||
grid.innerHTML = '';
|
||
|
||
const worldIcons = { 1: '☀️', 2: '🌙', 3: '🏊', 4: '🌫️', 5: '🏠' };
|
||
const worldNames = { 1: 'Солнечный День', 2: 'Ночной Двор', 3: 'Бассейн', 4: 'Ночной Туман', 5: 'Крыша Дома' };
|
||
|
||
this.levels.forEach(lvl => {
|
||
const wIcon = worldIcons[lvl.world] || '🌱';
|
||
const opt = document.createElement('option');
|
||
opt.value = lvl.level;
|
||
opt.textContent = `${lvl.code}: ${lvl.title} (${wIcon} ${lvl.biome})`;
|
||
dropdown.appendChild(opt);
|
||
|
||
const pill = document.createElement('div');
|
||
pill.className = `level-pill ${lvl.isBoss || lvl.is_boss ? 'is-boss' : ''} ${this.selectedLevel?.level === lvl.level ? 'active' : ''}`;
|
||
pill.id = `level-pill-${lvl.level}`;
|
||
pill.title = `${lvl.code} - ${lvl.title} (${worldNames[lvl.world] || 'Мир ' + lvl.world})`;
|
||
pill.innerHTML = `
|
||
<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 username = s.username || 'Гость';
|
||
const ipAddress = s.ipAddress || '127.0.0.1';
|
||
const deviceName = (s.deviceName && s.deviceName !== 'Unknown Device') ? s.deviceName : 'Браузер';
|
||
const createdAt = s.createdAt || '—';
|
||
const lastActive = s.lastActive || s.createdAt || 'Только что';
|
||
|
||
tr.innerHTML = `
|
||
<td>
|
||
<span class="player-name-link" data-user-id="${s.userId || ''}">${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); font-size:12px;">${ipAddress}</span></td>
|
||
<td><span style="color:var(--text-main); font-size:12px;">${deviceName}</span></td>
|
||
<td style="color:var(--text-dim); font-size:12px;">${createdAt}</td>
|
||
<td><span style="color:#a5d6a7; font-size:12px;">🟢 ${lastActive}</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)) ||
|
||
(l.ip && l.ip.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');
|
||
const timestamp = log.createdAt || log.timestamp || '—';
|
||
const username = log.username || 'Гость';
|
||
const ipAddress = log.ipAddress || log.ip || '127.0.0.1';
|
||
const deviceName = (log.deviceName && log.deviceName !== 'Unknown Device') ? log.deviceName : 'Браузер';
|
||
const status = log.status || 'unknown';
|
||
|
||
let statusBadge = `<span class="badge badge-muted">${status}</span>`;
|
||
if (status === 'success') {
|
||
statusBadge = `<span class="badge badge-success">✅ Успешно</span>`;
|
||
} else if (status === 'registered') {
|
||
statusBadge = `<span class="badge badge-purple">🎉 Регистрация</span>`;
|
||
} else if (status === 'wrong_password') {
|
||
statusBadge = `<span class="badge badge-danger">❌ Неверный пароль</span>`;
|
||
} else if (status === 'banned_login_attempt') {
|
||
statusBadge = `<span class="badge badge-warning">🚫 Заблокирован</span>`;
|
||
} else if (status === 'rate_limited') {
|
||
statusBadge = `<span class="badge badge-warning">⚠️ Превышен лимит</span>`;
|
||
} else if (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;">${timestamp}</td>
|
||
<td><b style="color:var(--accent-cyan);">${username}</b></td>
|
||
<td><span style="font-family:monospace; color:var(--text-muted); font-size:12px;">${ipAddress}</span></td>
|
||
<td style="font-size:12px; color:var(--text-main);">${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');
|
||
this.closeModal('modal-admin-password');
|
||
document.getElementById('admin-current-password').value = '';
|
||
document.getElementById('admin-new-password').value = '';
|
||
document.getElementById('admin-confirm-password').value = '';
|
||
} else {
|
||
errDiv.textContent = res.data.message || 'Ошибка смены пароля';
|
||
errDiv.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
// ==========================================
|
||
// PLAYERS TABLE & MODALS
|
||
// ==========================================
|
||
renderPlayersTable() {
|
||
const tbody = document.getElementById('players-table-body');
|
||
if (!tbody) return;
|
||
|
||
const search = document.getElementById('player-search-input')?.value.toLowerCase().trim() || '';
|
||
const filterStatus = document.getElementById('player-filter-status')?.value || 'all';
|
||
const sort = document.getElementById('player-sort-select')?.value || 'id_asc';
|
||
|
||
let list = [...this.users];
|
||
|
||
if (search) {
|
||
list = list.filter(u => u.username.toLowerCase().includes(search) || String(u.id).includes(search));
|
||
}
|
||
|
||
if (filterStatus === 'active') list = list.filter(u => !u.isBanned);
|
||
else if (filterStatus === 'banned') list = list.filter(u => u.isBanned);
|
||
else if (filterStatus === 'admin') list = list.filter(u => u.isAdmin);
|
||
|
||
list.sort((a, b) => {
|
||
if (sort === 'id_desc') return b.id - a.id;
|
||
if (sort === 'coins_desc') return b.coins - a.coins;
|
||
if (sort === 'level_desc') return b.level - a.level;
|
||
if (sort === 'login_desc') return (b.lastLogin || '').localeCompare(a.lastLogin || '');
|
||
return a.id - b.id;
|
||
});
|
||
|
||
if (list.length === 0) {
|
||
tbody.innerHTML = `<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) {
|
||
if (!this.isAuthenticated) {
|
||
this.showToast('Требуется авторизация администратора', 'warn');
|
||
return;
|
||
}
|
||
|
||
const res = await this.apiRequest(`/api/admin/user/${userId}`);
|
||
if (!res.ok || !res.data || !res.data.success) {
|
||
this.showToast('Ошибка загрузки статистики игрока', 'error');
|
||
return;
|
||
}
|
||
|
||
const u = res.data.user;
|
||
const prof = u.profile || u;
|
||
const st = prof.stats || u.stats || {};
|
||
const mats = prof.materials || u.materials || {};
|
||
const zen = prof.zenGarden || u.zenGarden || {};
|
||
const scores = u.scores || [];
|
||
|
||
const idInput = document.getElementById('stats-player-id');
|
||
if (idInput) idInput.value = u.id;
|
||
|
||
document.getElementById('stats-player-username').textContent = u.username || '—';
|
||
document.getElementById('stats-player-id-line').textContent = `#${u.id} (${prof.theme || 'classic'})`;
|
||
document.getElementById('stats-player-created').textContent = u.createdAt || '—';
|
||
document.getElementById('stats-player-login').textContent = u.lastLogin || '—';
|
||
|
||
let badge = '<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;
|
||
|
||
this.openModal('modal-player-stats');
|
||
}
|
||
|
||
async openEditPlayerModal(userId) {
|
||
if (!this.isAuthenticated) {
|
||
this.showToast('Требуется авторизация администратора', 'warn');
|
||
return;
|
||
}
|
||
|
||
const res = await this.apiRequest(`/api/admin/user/${userId}`);
|
||
if (!res.ok || !res.data || !res.data.success) {
|
||
this.showToast('Ошибка загрузки профиля игрока', 'error');
|
||
return;
|
||
}
|
||
|
||
const u = res.data.user;
|
||
const prof = u.profile || u;
|
||
const mats = prof.materials || u.materials || {};
|
||
const zen = prof.zenGarden || u.zenGarden || {};
|
||
|
||
document.getElementById('edit-player-id').value = u.id;
|
||
document.getElementById('edit-player-title-name').textContent = u.username || '';
|
||
document.getElementById('edit-player-coins').value = prof.coins ?? u.coins ?? 150;
|
||
document.getElementById('edit-player-level').value = prof.level ?? u.level ?? 1;
|
||
document.getElementById('edit-player-maxlevel').value = prof.maxUnlockedLevel ?? u.maxUnlockedLevel ?? 1;
|
||
document.getElementById('edit-player-highscore').value = prof.highScore ?? u.highScore ?? 0;
|
||
document.getElementById('edit-player-theme').value = prof.theme || u.theme || 'classic';
|
||
|
||
// Materials
|
||
document.getElementById('edit-player-mat-bioHumus').value = mats.bioHumus ?? 0;
|
||
document.getElementById('edit-player-mat-scrapMetal').value = mats.scrapMetal ?? 0;
|
||
document.getElementById('edit-player-mat-sunEssence').value = mats.sunEssence ?? 0;
|
||
document.getElementById('edit-player-mat-crystals').value = mats.crystals ?? 0;
|
||
|
||
// Zen Garden
|
||
document.getElementById('edit-player-zen-fertilizer').value = zen.fertilizerCount ?? 5;
|
||
document.getElementById('edit-player-zen-chocolate').value = zen.chocolateCount ?? 2;
|
||
|
||
document.getElementById('edit-player-is-admin').checked = !!u.isAdmin;
|
||
document.getElementById('edit-player-is-banned').checked = !!u.isBanned;
|
||
document.getElementById('edit-player-ban-reason').value = u.banReason || '';
|
||
|
||
const banCont = document.getElementById('edit-ban-reason-container');
|
||
if (banCont) banCont.style.display = u.isBanned ? 'block' : 'none';
|
||
|
||
this.openModal('modal-edit-player');
|
||
}
|
||
|
||
async savePlayerEdit() {
|
||
if (!this.isAuthenticated) {
|
||
this.showToast('Требуется авторизация администратора', 'warn');
|
||
return;
|
||
}
|
||
|
||
const id = document.getElementById('edit-player-id').value;
|
||
const updates = {
|
||
coins: parseInt(document.getElementById('edit-player-coins').value, 10) || 0,
|
||
level: parseInt(document.getElementById('edit-player-level').value, 10) || 1,
|
||
max_unlocked_level: parseInt(document.getElementById('edit-player-maxlevel').value, 10) || 1,
|
||
maxUnlockedLevel: parseInt(document.getElementById('edit-player-maxlevel').value, 10) || 1,
|
||
high_score: parseInt(document.getElementById('edit-player-highscore').value, 10) || 0,
|
||
highScore: parseInt(document.getElementById('edit-player-highscore').value, 10) || 0,
|
||
theme: document.getElementById('edit-player-theme').value,
|
||
materials: {
|
||
bioHumus: parseInt(document.getElementById('edit-player-mat-bioHumus').value, 10) || 0,
|
||
scrapMetal: parseInt(document.getElementById('edit-player-mat-scrapMetal').value, 10) || 0,
|
||
sunEssence: parseInt(document.getElementById('edit-player-mat-sunEssence').value, 10) || 0,
|
||
crystals: parseInt(document.getElementById('edit-player-mat-crystals').value, 10) || 0
|
||
},
|
||
zenGarden: {
|
||
fertilizerCount: parseInt(document.getElementById('edit-player-zen-fertilizer').value, 10) || 0,
|
||
chocolateCount: parseInt(document.getElementById('edit-player-zen-chocolate').value, 10) || 0
|
||
},
|
||
is_admin: document.getElementById('edit-player-is-admin').checked ? 1 : 0,
|
||
isAdmin: document.getElementById('edit-player-is-admin').checked,
|
||
is_banned: document.getElementById('edit-player-is-banned').checked ? 1 : 0,
|
||
isBanned: document.getElementById('edit-player-is-banned').checked,
|
||
ban_reason: document.getElementById('edit-player-ban-reason').value.trim(),
|
||
banReason: document.getElementById('edit-player-ban-reason').value.trim()
|
||
};
|
||
|
||
const res = await this.apiRequest(`/api/admin/user/${id}/edit`, 'POST', { updates });
|
||
if (res.ok && res.data && res.data.success) {
|
||
this.showToast('Профиль игрока и ресурсы успешно обновлены!', 'success');
|
||
this.closeModal('modal-edit-player');
|
||
this.loadUsers();
|
||
this.loadStats();
|
||
} else {
|
||
this.showToast((res.data && res.data.message) || 'Ошибка сохранения', 'error');
|
||
}
|
||
}
|
||
|
||
async createPlayer() {
|
||
if (!this.isAuthenticated) {
|
||
this.showToast('Требуется авторизация администратора', 'warn');
|
||
return;
|
||
}
|
||
|
||
const username = document.getElementById('create-player-username').value.trim();
|
||
const password = document.getElementById('create-player-password').value.trim();
|
||
const coins = parseInt(document.getElementById('create-player-coins').value, 10) || 150;
|
||
const isAdmin = document.getElementById('create-player-is-admin').checked;
|
||
|
||
if (!username || !password) {
|
||
this.showToast('Укажите имя пользователя и пароль', 'warn');
|
||
return;
|
||
}
|
||
|
||
const res = await this.apiRequest('/api/admin/user/create', 'POST', {
|
||
username,
|
||
password,
|
||
isAdmin,
|
||
initialData: { coins, level: 1, maxUnlockedLevel: 1 }
|
||
});
|
||
|
||
if (res.ok && res.data && res.data.success) {
|
||
this.showToast(`Игрок ${username} успешно создан!`, 'success');
|
||
this.closeModal('modal-create-player');
|
||
document.getElementById('create-player-username').value = '';
|
||
document.getElementById('create-player-password').value = '';
|
||
this.loadUsers();
|
||
this.loadStats();
|
||
} else {
|
||
this.showToast((res.data && res.data.message) || 'Ошибка создания игрока', 'error');
|
||
}
|
||
}
|
||
|
||
openResetPasswordModal(id, username) {
|
||
if (!this.isAuthenticated) {
|
||
this.showToast('Требуется авторизация администратора', 'warn');
|
||
return;
|
||
}
|
||
|
||
document.getElementById('reset-pwd-user-id').value = id;
|
||
document.getElementById('reset-pwd-username').textContent = username;
|
||
document.getElementById('reset-pwd-input').value = '';
|
||
this.openModal('modal-reset-password');
|
||
}
|
||
|
||
async submitResetPassword() {
|
||
if (!this.isAuthenticated) {
|
||
this.showToast('Требуется авторизация администратора', 'warn');
|
||
return;
|
||
}
|
||
|
||
const id = document.getElementById('reset-pwd-user-id').value;
|
||
const pwd = document.getElementById('reset-pwd-input').value.trim();
|
||
if (!pwd) {
|
||
this.showToast('Введите новый пароль', 'warn');
|
||
return;
|
||
}
|
||
|
||
const res = await this.apiRequest(`/api/admin/user/${id}/password`, 'POST', { password: pwd });
|
||
if (res.ok && res.data && res.data.success) {
|
||
this.showToast('Пароль успешно сброшен', 'success');
|
||
this.closeModal('modal-reset-password');
|
||
} else {
|
||
this.showToast((res.data && res.data.message) || 'Ошибка сброса пароля', 'error');
|
||
}
|
||
}
|
||
|
||
async deletePlayer(id, username) {
|
||
if (!confirm(`Вы действительно хотите удалить игрока "${username}" (#${id}) и все его данные безвозвратно?`)) {
|
||
return;
|
||
}
|
||
|
||
const res = await this.apiRequest(`/api/admin/user/${id}/delete`, 'POST');
|
||
if (res.ok && res.data.success) {
|
||
this.showToast(`Игрок ${username} удален`, 'success');
|
||
this.loadUsers();
|
||
this.loadStats();
|
||
this.loadSessions();
|
||
} else {
|
||
this.showToast(res.data.message || 'Ошибка удаления', 'error');
|
||
}
|
||
}
|
||
|
||
// ==========================================
|
||
// BUGS MODERATION
|
||
// ==========================================
|
||
renderBugsTable() {
|
||
const tbody = document.getElementById('bugs-table-body');
|
||
if (!tbody) return;
|
||
|
||
const filterStatus = document.getElementById('bug-filter-status')?.value || 'all';
|
||
const filterCat = document.getElementById('bug-filter-category')?.value || 'all';
|
||
|
||
let list = [...this.bugs];
|
||
if (filterStatus !== 'all') list = list.filter(b => b.status === filterStatus);
|
||
if (filterCat !== 'all') list = list.filter(b => b.category === filterCat);
|
||
|
||
const badge = document.getElementById('badge-bugs-count');
|
||
if (badge) {
|
||
const newCount = this.bugs.filter(b => b.status === 'новый').length;
|
||
badge.textContent = newCount;
|
||
badge.style.display = newCount > 0 ? 'inline-block' : 'none';
|
||
}
|
||
|
||
if (list.length === 0) {
|
||
tbody.innerHTML = `<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 || '';
|
||
|
||
this.openModal('modal-moderate-bug');
|
||
}
|
||
|
||
async saveBugModeration() {
|
||
const id = parseInt(document.getElementById('bug-mod-id').textContent, 10);
|
||
const status = document.getElementById('bug-mod-status').value;
|
||
const adminReply = document.getElementById('bug-mod-reply').value.trim();
|
||
|
||
const res = await this.apiRequest(`/api/admin/bugs/${id}/status`, 'POST', { status, adminReply });
|
||
if (res.ok && res.data.success) {
|
||
this.showToast(`Баг #${id} обновлен`, 'success');
|
||
this.closeModal('modal-moderate-bug');
|
||
this.loadBugs();
|
||
} else {
|
||
this.showToast(res.data.message || 'Ошибка сохранения', 'error');
|
||
}
|
||
}
|
||
|
||
async deleteBug() {
|
||
const id = parseInt(document.getElementById('bug-mod-id').textContent, 10);
|
||
if (!confirm(`Удалить баг-репорт #${id}?`)) return;
|
||
|
||
const res = await this.apiRequest(`/api/admin/bugs/${id}/status`, 'POST', { delete: true });
|
||
if (res.ok && res.data.success) {
|
||
this.showToast(`Баг #${id} удален`, 'success');
|
||
this.closeModal('modal-moderate-bug');
|
||
this.loadBugs();
|
||
} else {
|
||
this.showToast(res.data.message || 'Ошибка удаления', 'error');
|
||
}
|
||
}
|
||
|
||
// ==========================================
|
||
// CONFIG PRESETS & BALANCE SAVE
|
||
// ==========================================
|
||
async applyPreset(presetName) {
|
||
if (!this.isAuthenticated) {
|
||
this.showToast('Необходима авторизация администратора', 'warn');
|
||
return;
|
||
}
|
||
|
||
const res = await this.apiRequest('/api/admin/config', 'POST', { applyPreset: presetName });
|
||
if (res.ok && res.data.success) {
|
||
this.showToast(`Пресет "${presetName}" успешно активирован на сервере!`, 'success');
|
||
this.config = res.data.config;
|
||
this.populateUIFromConfig();
|
||
this.loadStats();
|
||
} else {
|
||
this.showToast(res.data.message || 'Ошибка применения пресета', 'error');
|
||
}
|
||
}
|
||
|
||
async saveBalanceSection(sectionName) {
|
||
if (!this.isAuthenticated) {
|
||
this.showToast('Необходима авторизация администратора', 'warn');
|
||
return;
|
||
}
|
||
|
||
if (!this.config) this.config = {};
|
||
|
||
if (sectionName === 'economy') {
|
||
this.config.economy = {
|
||
sunMultiplier: parseFloat(document.getElementById('econ-sunMultiplier').value),
|
||
startingSun: parseInt(document.getElementById('econ-startingSun').value, 10),
|
||
coinDropRate: parseFloat(document.getElementById('econ-coinDropRate').value),
|
||
sunDropRate: parseFloat(document.getElementById('econ-sunDropRate').value),
|
||
plantCostMultiplier: parseFloat(document.getElementById('econ-plantCostMultiplier').value),
|
||
upgradeCostMultiplier: parseFloat(document.getElementById('econ-upgradeCostMultiplier').value)
|
||
};
|
||
} else if (sectionName === 'zombies') {
|
||
this.config.zombies = {
|
||
hpMultiplier: parseFloat(document.getElementById('zom-hpMultiplier').value),
|
||
speedMultiplier: parseFloat(document.getElementById('zom-speedMultiplier').value),
|
||
damageMultiplier: parseFloat(document.getElementById('zom-damageMultiplier').value),
|
||
waveDensityMultiplier: parseFloat(document.getElementById('zom-waveDensityMultiplier').value)
|
||
};
|
||
} else if (sectionName === 'plants') {
|
||
this.config.plants = {
|
||
damageMultiplier: parseFloat(document.getElementById('plant-damageMultiplier').value),
|
||
hpMultiplier: parseFloat(document.getElementById('plant-hpMultiplier').value),
|
||
cooldownMultiplier: parseFloat(document.getElementById('plant-cooldownMultiplier').value),
|
||
sunProduceIntervalMultiplier: parseFloat(document.getElementById('plant-sunProduceIntervalMultiplier').value)
|
||
};
|
||
} else if (sectionName === 'zenGarden') {
|
||
this.config.zenGarden = {
|
||
dropRateMultiplier: parseFloat(document.getElementById('zen-dropRateMultiplier').value),
|
||
growthRateMultiplier: parseFloat(document.getElementById('zen-growthRateMultiplier').value),
|
||
fertilizerMultiplier: parseFloat(document.getElementById('zen-fertilizerMultiplier').value),
|
||
waterIntervalMinutes: parseInt(document.getElementById('zen-waterIntervalMinutes').value, 10)
|
||
};
|
||
} else if (sectionName === 'events') {
|
||
this.config.events = {
|
||
doubleGold: document.getElementById('event-doubleGold').checked,
|
||
fastCooldowns: document.getElementById('event-fastCooldowns').checked,
|
||
infiniteSun: document.getElementById('event-infiniteSun').checked,
|
||
turboZombies: document.getElementById('event-turboZombies').checked,
|
||
activeEventName: 'custom'
|
||
};
|
||
}
|
||
|
||
this.config.preset = 'Кастомный (Custom)';
|
||
|
||
const res = await this.apiRequest('/api/admin/config', 'POST', { config: this.config });
|
||
if (res.ok && res.data.success) {
|
||
this.showToast(`Параметры раздела "${sectionName}" сохранены!`, 'success');
|
||
this.loadStats();
|
||
} else {
|
||
this.showToast(res.data.message || 'Ошибка сохранения', 'error');
|
||
}
|
||
}
|
||
|
||
// ==========================================
|
||
// BACKUPS EXPORT & IMPORT
|
||
// ==========================================
|
||
async exportBackup() {
|
||
if (!this.isAuthenticated) {
|
||
this.showToast('Необходима авторизация администратора', 'warn');
|
||
return;
|
||
}
|
||
|
||
const res = await this.apiRequest('/api/admin/backup/export');
|
||
if (res.ok && res.data.success) {
|
||
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(res.data.data, null, 2));
|
||
const downloadAnchor = document.createElement('a');
|
||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||
downloadAnchor.setAttribute("href", dataStr);
|
||
downloadAnchor.setAttribute("download", `pvz_full_backup_${timestamp}.json`);
|
||
document.body.appendChild(downloadAnchor);
|
||
downloadAnchor.click();
|
||
downloadAnchor.remove();
|
||
this.showToast('Резервная копия успешно выгружена!', 'success');
|
||
} else {
|
||
this.showToast(res.data.message || 'Ошибка выгрузки бэкапа', 'error');
|
||
}
|
||
}
|
||
|
||
async importBackup(file) {
|
||
if (!this.isAuthenticated) {
|
||
this.showToast('Необходима авторизация администратора', 'warn');
|
||
return;
|
||
}
|
||
|
||
const reader = new FileReader();
|
||
reader.onload = async (e) => {
|
||
try {
|
||
const jsonData = JSON.parse(e.target.result);
|
||
if (!confirm(`Вы действительно хотите восстановить базу данных из бэкапа "${file.name}"? Текущие данные будут перезаписаны!`)) {
|
||
return;
|
||
}
|
||
|
||
const res = await this.apiRequest('/api/admin/backup/import', 'POST', { data: jsonData });
|
||
if (res.ok && res.data.success) {
|
||
this.showToast('База данных успешно восстановлена!', 'success');
|
||
this.loadAllData();
|
||
} else {
|
||
this.showToast(res.data.message || 'Ошибка импорта бэкапа', 'error');
|
||
}
|
||
} catch (err) {
|
||
this.showToast('Неверный формат JSON файла бэкапа', 'error');
|
||
}
|
||
};
|
||
reader.readAsText(file);
|
||
}
|
||
|
||
// ==========================================
|
||
// EVENT BINDINGS
|
||
// ==========================================
|
||
bindEvents() {
|
||
// Navigation Tabs
|
||
document.querySelectorAll('.nav-tab-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
document.querySelectorAll('.nav-tab-btn').forEach(b => b.classList.remove('active'));
|
||
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
|
||
|
||
btn.classList.add('active');
|
||
const target = btn.getAttribute('data-tab');
|
||
const pane = document.getElementById(target);
|
||
if (pane) pane.classList.add('active');
|
||
this.activeTab = target;
|
||
|
||
if (target === 'tab-players') this.loadUsers();
|
||
if (target === 'tab-sessions') this.loadSessions();
|
||
if (target === 'tab-logs') this.loadLoginLogs();
|
||
if (target === 'tab-levels' && this.levels.length === 0) this.loadCampaignLevels();
|
||
});
|
||
});
|
||
|
||
// Full-Screen Login Screen Bindings
|
||
const btnScreenSubmit = document.getElementById('btn-login-screen-submit');
|
||
if (btnScreenSubmit) {
|
||
btnScreenSubmit.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
this.handleScreenLogin();
|
||
});
|
||
}
|
||
|
||
const screenPwdInput = document.getElementById('login-screen-password');
|
||
if (screenPwdInput) {
|
||
screenPwdInput.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
this.handleScreenLogin();
|
||
}
|
||
});
|
||
}
|
||
|
||
const btnTogglePwd = document.getElementById('btn-toggle-login-password');
|
||
if (btnTogglePwd && screenPwdInput) {
|
||
btnTogglePwd.addEventListener('click', () => {
|
||
if (screenPwdInput.type === 'password') {
|
||
screenPwdInput.type = 'text';
|
||
btnTogglePwd.textContent = '🙈';
|
||
} else {
|
||
screenPwdInput.type = 'password';
|
||
btnTogglePwd.textContent = '👁️';
|
||
}
|
||
});
|
||
}
|
||
|
||
// Login modal
|
||
const btnLoginOpen = document.getElementById('btn-login-modal-open');
|
||
if (btnLoginOpen) {
|
||
btnLoginOpen.addEventListener('click', () => {
|
||
const err = document.getElementById('login-error-msg');
|
||
if (err) err.style.display = 'none';
|
||
const input = document.getElementById('admin-password-input');
|
||
if (input) input.value = '';
|
||
this.openModal('modal-login');
|
||
});
|
||
}
|
||
|
||
const btnSubmitLogin = document.getElementById('btn-submit-login');
|
||
if (btnSubmitLogin) {
|
||
btnSubmitLogin.addEventListener('click', async () => {
|
||
const input = document.getElementById('admin-password-input');
|
||
const pwd = input ? input.value.trim() : '';
|
||
if (!pwd) return;
|
||
|
||
const res = await this.apiRequest('/api/admin/auth', 'POST', { password: pwd });
|
||
if (res.ok && res.data && res.data.success && res.data.token) {
|
||
this.token = res.data.token;
|
||
localStorage.setItem('pvz_admin_token', this.token);
|
||
this.closeModal('modal-login');
|
||
this.showToast('Авторизация администратора успешна!', 'success');
|
||
this.onAuthenticated();
|
||
} else {
|
||
const err = document.getElementById('login-error-msg');
|
||
if (err) {
|
||
err.textContent = (res.data && res.data.message) || 'Неверный пароль администратора';
|
||
err.style.display = 'block';
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// Change Admin Password Modal
|
||
const btnOpenPwd = document.getElementById('btn-admin-password-modal-open');
|
||
if (btnOpenPwd) {
|
||
btnOpenPwd.addEventListener('click', () => {
|
||
const err = document.getElementById('admin-password-error');
|
||
if (err) err.style.display = 'none';
|
||
const cur = document.getElementById('admin-current-password');
|
||
if (cur) cur.value = '';
|
||
const nw = document.getElementById('admin-new-password');
|
||
if (nw) nw.value = '';
|
||
const cf = document.getElementById('admin-confirm-password');
|
||
if (cf) cf.value = '';
|
||
this.openModal('modal-admin-password');
|
||
});
|
||
}
|
||
|
||
const btnSubmitPwd = document.getElementById('btn-submit-change-admin-password');
|
||
if (btnSubmitPwd) {
|
||
btnSubmitPwd.addEventListener('click', () => this.changeAdminPassword());
|
||
}
|
||
|
||
// Logout
|
||
const btnLogout = document.getElementById('btn-admin-logout');
|
||
if (btnLogout) {
|
||
btnLogout.addEventListener('click', () => {
|
||
this.token = '';
|
||
localStorage.removeItem('pvz_admin_token');
|
||
this.onUnauthenticated();
|
||
this.showToast('Вы вышли из панели администратора', 'info');
|
||
});
|
||
}
|
||
|
||
// Close Modals
|
||
document.querySelectorAll('[data-close]').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const modalId = btn.getAttribute('data-close');
|
||
this.closeModal(modalId);
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll('.modal-overlay').forEach(overlay => {
|
||
overlay.addEventListener('click', (e) => {
|
||
if (e.target === overlay) {
|
||
overlay.classList.remove('active', 'open');
|
||
}
|
||
});
|
||
});
|
||
|
||
// Campaign Level Editor Actions
|
||
const btnSaveLevel = document.getElementById('btn-save-level');
|
||
if (btnSaveLevel) btnSaveLevel.addEventListener('click', () => this.saveCurrentLevel());
|
||
|
||
const btnResetLevel = document.getElementById('btn-reset-level');
|
||
if (btnResetLevel) btnResetLevel.addEventListener('click', () => this.resetCurrentLevel());
|
||
|
||
const btnReloadLevels = document.getElementById('btn-reload-levels');
|
||
if (btnReloadLevels) btnReloadLevels.addEventListener('click', () => this.loadCampaignLevels());
|
||
|
||
const btnCreateLvl = document.getElementById('btn-create-custom-level');
|
||
if (btnCreateLvl) btnCreateLvl.addEventListener('click', () => this.createNewLevel());
|
||
|
||
const btnAddWave = document.getElementById('btn-add-wave');
|
||
if (btnAddWave) btnAddWave.addEventListener('click', () => this.addWave());
|
||
|
||
// Dialogue Live Preview Binds
|
||
['dialogue-speaker', 'dialogue-title', 'dialogue-text', 'dialogue-btn-text'].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.addEventListener('input', () => this.updateDialoguePreview());
|
||
});
|
||
|
||
// Sessions Actions
|
||
const btnRefSessions = document.getElementById('btn-refresh-sessions');
|
||
if (btnRefSessions) btnRefSessions.addEventListener('click', () => this.loadSessions());
|
||
|
||
const searchSessions = document.getElementById('sessions-search-input');
|
||
if (searchSessions) searchSessions.addEventListener('input', (e) => this.renderSessionsTable(e.target.value));
|
||
|
||
// Logs Actions
|
||
const btnRefLogs = document.getElementById('btn-refresh-logs');
|
||
if (btnRefLogs) btnRefLogs.addEventListener('click', () => this.loadLoginLogs());
|
||
|
||
['logs-filter-username', 'logs-filter-ip', 'logs-filter-status'].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.addEventListener('input', () => this.renderLoginLogsTable());
|
||
if (el) el.addEventListener('change', () => this.renderLoginLogsTable());
|
||
});
|
||
|
||
// Player Actions
|
||
const btnCreatePlayerOpen = document.getElementById('btn-create-player-open');
|
||
if (btnCreatePlayerOpen) {
|
||
btnCreatePlayerOpen.addEventListener('click', () => {
|
||
this.openModal('modal-create-player');
|
||
});
|
||
}
|
||
const btnSubmitCreatePlayer = document.getElementById('btn-submit-create-player');
|
||
if (btnSubmitCreatePlayer) {
|
||
btnSubmitCreatePlayer.addEventListener('click', () => this.createPlayer());
|
||
}
|
||
const btnSavePlayerEdit = document.getElementById('btn-save-player-edit');
|
||
if (btnSavePlayerEdit) {
|
||
btnSavePlayerEdit.addEventListener('click', () => this.savePlayerEdit());
|
||
}
|
||
const btnSubmitResetPwd = document.getElementById('btn-submit-reset-pwd');
|
||
if (btnSubmitResetPwd) {
|
||
btnSubmitResetPwd.addEventListener('click', () => this.submitResetPassword());
|
||
}
|
||
const btnRefreshPlayers = document.getElementById('btn-refresh-players');
|
||
if (btnRefreshPlayers) {
|
||
btnRefreshPlayers.addEventListener('click', () => this.loadUsers());
|
||
}
|
||
const playerSearchInput = document.getElementById('player-search-input');
|
||
if (playerSearchInput) {
|
||
playerSearchInput.addEventListener('input', () => this.renderPlayersTable());
|
||
}
|
||
const playerFilterStatus = document.getElementById('player-filter-status');
|
||
if (playerFilterStatus) {
|
||
playerFilterStatus.addEventListener('change', () => this.renderPlayersTable());
|
||
}
|
||
const playerSortSelect = document.getElementById('player-sort-select');
|
||
if (playerSortSelect) {
|
||
playerSortSelect.addEventListener('change', () => this.renderPlayersTable());
|
||
}
|
||
const editPlayerIsBanned = document.getElementById('edit-player-is-banned');
|
||
if (editPlayerIsBanned) {
|
||
editPlayerIsBanned.addEventListener('change', (e) => {
|
||
const container = document.getElementById('edit-ban-reason-container');
|
||
if (container) container.style.display = e.target.checked ? 'block' : 'none';
|
||
});
|
||
}
|
||
|
||
const btnStatsJumpEdit = document.getElementById('btn-stats-jump-edit');
|
||
if (btnStatsJumpEdit) {
|
||
btnStatsJumpEdit.addEventListener('click', () => {
|
||
const id = document.getElementById('stats-player-id')?.value;
|
||
this.closeModal('modal-player-stats');
|
||
if (id) this.openEditPlayerModal(id);
|
||
});
|
||
}
|
||
|
||
// Balance Save Buttons
|
||
document.getElementById('btn-save-economy').addEventListener('click', () => this.saveBalanceSection('economy'));
|
||
document.getElementById('btn-save-zombies').addEventListener('click', () => this.saveBalanceSection('zombies'));
|
||
document.getElementById('btn-save-plants').addEventListener('click', () => this.saveBalanceSection('plants'));
|
||
document.getElementById('btn-save-zengarden').addEventListener('click', () => this.saveBalanceSection('zenGarden'));
|
||
document.getElementById('btn-save-events-quick').addEventListener('click', () => this.saveBalanceSection('events'));
|
||
|
||
// Presets
|
||
document.querySelectorAll('.btn-apply-preset').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const preset = btn.getAttribute('data-preset');
|
||
this.applyPreset(preset);
|
||
});
|
||
});
|
||
|
||
// Bugs Actions
|
||
document.getElementById('btn-refresh-bugs').addEventListener('click', () => this.loadBugs());
|
||
document.getElementById('bug-filter-status').addEventListener('change', () => this.renderBugsTable());
|
||
document.getElementById('bug-filter-category').addEventListener('change', () => this.renderBugsTable());
|
||
document.getElementById('btn-bug-mod-save').addEventListener('click', () => this.saveBugModeration());
|
||
document.getElementById('btn-bug-delete').addEventListener('click', () => this.deleteBug());
|
||
|
||
// Backups Actions
|
||
document.getElementById('btn-export-backup').addEventListener('click', () => this.exportBackup());
|
||
document.getElementById('btn-trigger-import').addEventListener('click', () => {
|
||
document.getElementById('backup-file-input').click();
|
||
});
|
||
document.getElementById('backup-file-input').addEventListener('change', (e) => {
|
||
if (e.target.files && e.target.files[0]) {
|
||
this.importBackup(e.target.files[0]);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// Instantiate on DOM Load
|
||
window.addEventListener('DOMContentLoaded', () => {
|
||
window.pvzConfigurator = new PvZConfiguratorApp();
|
||
});
|