/
ankarr
/
web-nodejs-labs
Обзор
Документация
Войти
/
ankarr
/
web-nodejs-labs
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
public/lab9/task2/script.js
575 строк
18 KB
unknown
lab 10
16 дек 2025, 10:39
16 дек 2025, 10:39
0259f91
Код
Авторство
О чём код?
const toggle = document.getElementById('themeToggle'); function applyTheme(isDark) { const html = document.getElementById('themeRoot'); if (isDark) { html.setAttribute('data-theme', 'dark'); toggle.textContent = '🔆'; } else { html.removeAttribute('data-theme'); toggle.textContent = '🌓'; } localStorage.setItem('theme', isDark ? 'dark' : 'light'); } const saved = localStorage.getItem('theme') || 'light'; applyTheme(saved === 'dark'); toggle.addEventListener('click', () => { const isDark = localStorage.getItem('theme') === 'dark'; applyTheme(!isDark); }); function formatPrice(cents) { return (cents / 100).toLocaleString('ru-RU', { style: 'currency', currency: 'RUB', minimumFractionDigits: 2 }); } function parsePrice(rubles) { return Math.round(parseFloat(rubles) * 100); } let currentProducts = []; let currentSort = { column: 'id', direction: 'desc' }; let pendingChanges = {}; async function loadStockStats() { try { const res = await fetch('/api/lab9/task1/stock'); const stats = await res.json(); document.getElementById('totalProducts').textContent = stats.total_products; document.getElementById('totalItems').textContent = stats.total_items; document.getElementById('totalValue').textContent = formatPrice(stats.total_value); } catch (error) { console.error('Ошибка загрузки статистики:', error); showError('Не удалось загрузить статистику склада'); } } async function loadAllProducts() { try { const res = await fetch('/api/lab9/products'); const products = await res.json(); currentProducts = products; pendingChanges = {}; sortAndRenderTable(); updateSaveButtonState(); } catch (error) { console.error('Ошибка загрузки товаров:', error); document.getElementById('productsBody').innerHTML = ` <tr> <td colspan="5" style="text-align: center; color: var(--error-color);"> <div class="error-message"> <p>❌ Ошибка загрузки товаров</p> <button onclick="loadAllProducts()" class="retry-btn">Повторить попытку</button> </div> </td> </tr> `; showError('Не удалось загрузить список товаров'); } } function sortAndRenderTable() { const sorted = [...currentProducts].sort((a, b) => { let valueA = a[currentSort.column]; let valueB = b[currentSort.column]; if (currentSort.column === 'price') { valueA = parseInt(valueA); valueB = parseInt(valueB); } else if (currentSort.column === 'amount') { valueA = parseInt(valueA); valueB = parseInt(valueB); } let comparison = 0; if (valueA > valueB) comparison = 1; else if (valueA < valueB) comparison = -1; return currentSort.direction === 'asc' ? comparison : -comparison; }); renderTable(sorted); updateSortIndicators(); } function updateSortIndicators() { document.querySelectorAll('.sort-indicator').forEach(el => { el.textContent = ''; }); const indicator = document.getElementById(`sort-${currentSort.column}`); if (indicator) { indicator.textContent = currentSort.direction === 'asc' ? '↑' : '↓'; } } function renderTable(products) { const tbody = document.getElementById('productsBody'); if (products.length === 0) { tbody.innerHTML = '<tr><td colspan="5" style="text-align: center;">Товары не найдены</td></tr>'; return; } tbody.innerHTML = products.map(p => ` <tr data-id="${p.id}"> <td>${p.id}</td> <td> <input type="text" class="editable ${isFieldChanged(p.id, 'title') ? 'changed' : ''}" data-id="${p.id}" data-field="title" value="${p.title}" oninput="trackChange(${p.id}, 'title', this.value)" placeholder="Введите название товара"> </td> <td> <input type="number" class="editable ${isFieldChanged(p.id, 'price') ? 'changed' : ''}" data-id="${p.id}" data-field="price" value="${p.price / 100}" step="0.01" min="0.01" oninput="trackChange(${p.id}, 'price', this.value)"> </td> <td> <div class="amount-controls"> <button class="btn-sm" onclick="decreaseAmount(${p.id})" ${p.amount <= 0 ? 'disabled' : ''}>-</button> <input type="number" class="editable amount-input ${isFieldChanged(p.id, 'amount') ? 'changed' : ''}" data-id="${p.id}" data-field="amount" value="${p.amount}" min="0" max="999" style="width: 60px; text-align: center;" oninput="trackChange(${p.id}, 'amount', this.value)"> <button class="btn-sm" onclick="increaseAmount(${p.id})">+</button> </div> </td> <td> <div class="action-buttons"> <button class="btn-sm delete-btn" onclick="deleteProduct(${p.id})" title="Удалить товар">🗑️</button> ${Object.keys(pendingChanges[p?.id] || {}).length > 0 ? `<button class="btn-sm cancel-btn" onclick="cancelChanges(${p.id})" title="Отменить изменения">↩️</button>` : ''} </div> </td> </tr> `).join(''); updateSaveButtonState(); } function isFieldChanged(id, field) { return pendingChanges[id] && pendingChanges[id][field] !== undefined; } function trackChange(id, field, value) { if (field === 'price') { const numValue = parseFloat(value); if (isNaN(numValue) || numValue <= 0) { showError('Цена должна быть положительным числом'); return; } } if (field === 'amount') { const numValue = parseInt(value); if (isNaN(numValue) || numValue < 0) { showError('Количество не может быть отрицательным'); return; } } if (!pendingChanges[id]) { pendingChanges[id] = {}; } const originalValue = currentProducts.find(p => p.id == id)?.[field]; if (value !== originalValue?.toString()) { pendingChanges[id][field] = value; } else { delete pendingChanges[id][field]; if (Object.keys(pendingChanges[id]).length === 0) { delete pendingChanges[id]; } } const input = document.querySelector(`input[data-id="${id}"][data-field="${field}"]`); if (input) { input.classList.toggle('changed', isFieldChanged(id, field)); } updateSaveButtonState(); } function cancelChanges(id) { delete pendingChanges[id]; const product = currentProducts.find(p => p.id == id); if (product) { document.querySelector(`input[data-id="${id}"][data-field="title"]`).value = product.title; document.querySelector(`input[data-id="${id}"][data-field="price"]`).value = product.price / 100; document.querySelector(`input[data-id="${id}"][data-field="amount"]`).value = product.amount; document.querySelectorAll(`tr[data-id="${id}"] input`).forEach(input => { input.classList.remove('changed'); }); } updateSaveButtonState(); showNotification('Изменения для товара отменены', 'info'); } function updateSaveButtonState() { const saveBtn = document.querySelector('.save-all-btn'); const hasChanges = Object.keys(pendingChanges).length > 0; saveBtn.disabled = !hasChanges; saveBtn.classList.toggle('btn-primary', hasChanges); saveBtn.classList.toggle('btn-disabled', !hasChanges); } async function saveAllChanges() { const saveBtn = document.querySelector('.save-all-btn'); const originalText = saveBtn.innerHTML; if (Object.keys(pendingChanges).length === 0) { alert('Нет изменений для сохранения'); return; } saveBtn.disabled = true; saveBtn.innerHTML = '<span class="spinner"></span> Сохранение...'; let successCount = 0; let errorCount = 0; const errors = []; const changes = Object.entries(pendingChanges); for (const [id, changesObj] of changes) { try { const data = {}; if (changesObj.title !== undefined && (!changesObj.title || changesObj.title.trim() === '')) { throw new Error('Название товара не может быть пустым'); } for (const [field, value] of Object.entries(changesObj)) { if (field === 'price') { const numValue = parseFloat(value); if (isNaN(numValue) || numValue <= 0) { throw new Error('Некорректная цена'); } data[field] = parsePrice(value); } else if (field === 'amount') { const numValue = parseInt(value); if (isNaN(numValue) || numValue < 0) { throw new Error('Некорректное количество'); } data[field] = numValue; } else { data[field] = value.trim(); } } const res = await fetch(`/api/lab9/products/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); if (!res.ok) { const errorData = await res.json().catch(() => ({})); throw new Error(errorData.error || `Ошибка ${res.status}`); } successCount++; } catch (error) { console.error(`Ошибка сохранения товара ${id}:`, error); errors.push(`Товар ${id}: ${error.message}`); errorCount++; } } await loadAllProducts(); await loadStockStats(); saveBtn.innerHTML = originalText; saveBtn.disabled = false; if (errorCount === 0) { showNotification(`✅ Успешно сохранено ${successCount} ${getNoun(successCount, 'изменение', 'изменения', 'изменений')}`, 'success'); } else { let message = `⚠️ Сохранено ${successCount} ${getNoun(successCount, 'изменение', 'изменения', 'изменений')}, ошибок: ${errorCount}`; if (errors.length > 0) { message += `\n\nДетали ошибок:\n${errors.join('\n')}`; } alert(message); } } function getNoun(number, one, two, five) { let n = Math.abs(number); n %= 100; if (n >= 5 && n <= 20) { return five; } n %= 10; if (n === 1) { return one; } if (n >= 2 && n <= 4) { return two; } return five; } function showNotification(message, type = 'info') { const existing = document.querySelector('.notification'); if (existing) existing.remove(); const notification = document.createElement('div'); notification.className = `notification ${type}`; notification.innerHTML = message; document.body.appendChild(notification); setTimeout(() => { notification.classList.add('fade-out'); setTimeout(() => notification.remove(), 300); }, 3000); } function showError(message) { console.error(message); showNotification(`❌ ${message}`, 'error'); } function sortTable(column) { if (currentSort.column === column) { currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc'; } else { currentSort.column = column; currentSort.direction = 'asc'; } sortAndRenderTable(); } async function loadProductById() { const idInput = document.getElementById('productIdInput'); const id = idInput.value.trim(); if (!id) { showError('Введите ID товара'); idInput.focus(); return; } const detailsContainer = document.getElementById('productDetails'); detailsContainer.innerHTML = '<div class="loading-spinner"></div>'; try { const res = await fetch(`/api/lab9/products/${id}`); if (!res.ok) { if (res.status === 404) { detailsContainer.innerHTML = '<p class="error-message">Товар с таким ID не найден</p>'; } else { detailsContainer.innerHTML = `<p class="error-message">Ошибка сервера: ${res.status}</p>`; } return; } const product = await res.json(); detailsContainer.innerHTML = ` <div class="product-details-card"> <div class="detail-row"><span class="detail-label">ID:</span> <span>${product.id}</span></div> <div class="detail-row"><span class="detail-label">Название:</span> <span>${product.title}</span></div> <div class="detail-row"><span class="detail-label">Цена:</span> <span>${formatPrice(product.price)}</span></div> <div class="detail-row"><span class="detail-label">Остаток:</span> <span>${product.amount} шт.</span></div> <div class="detail-row"><span class="detail-label">Общая стоимость:</span> <span>${formatPrice(product.price * product.amount)}</span> </div> </div> `; } catch (error) { console.error('Ошибка загрузки товара:', error); detailsContainer.innerHTML = `<p class="error-message">Ошибка при загрузке товара: ${error.message}</p>`; showError('Не удалось загрузить информацию о товаре'); } } document.getElementById('productIdInput').addEventListener('keypress', (e) => { if (e.key === 'Enter') { loadProductById(); } }); function increaseAmount(id) { const input = document.querySelector(`input[data-id="${id}"][data-field="amount"]`); if (input) { const currentValue = parseInt(input.value) || 0; const newValue = currentValue + 1; input.value = newValue; input.dispatchEvent(new Event('input')); } } function decreaseAmount(id) { const input = document.querySelector(`input[data-id="${id}"][data-field="amount"]`); if (input) { const currentValue = parseInt(input.value) || 0; if (currentValue > 0) { const newValue = currentValue - 1; input.value = newValue; input.dispatchEvent(new Event('input')); } } } async function deleteProduct(id) { if (!confirm(`Вы уверены, что хотите удалить товар #${id}? Это действие нельзя отменить.`)) return; try { const res = await fetch(`/api/lab9/products/${id}`, { method: 'DELETE' }); if (!res.ok) { const errorData = await res.json().catch(() => ({})); throw new Error(errorData.error || `Ошибка ${res.status}`); } showNotification(`Товар #${id} успешно удалён`, 'success'); loadAllProducts(); loadStockStats(); } catch (error) { console.error('Ошибка удаления:', error); showError(`Не удалось удалить товар: ${error.message}`); } } function cancelAllChanges() { if (Object.keys(pendingChanges).length === 0) return; if (confirm('Отменить все несохранённые изменения?')) { pendingChanges = {}; sortAndRenderTable(); showNotification('Все изменения отменены', 'info'); } } async function addNewProduct() { const newProduct = { title: prompt('Введите название нового товара:', 'Новый товар'), price: parseFloat(prompt('Введите цену в рублях:', '100.00')), amount: parseInt(prompt('Введите начальное количество:', '10')) }; if (!newProduct.title || newProduct.title.trim() === '') { showError('Название товара не может быть пустым'); return; } if (isNaN(newProduct.price) || newProduct.price <= 0) { showError('Цена должна быть положительным числом'); return; } if (isNaN(newProduct.amount) || newProduct.amount < 0) { showError('Количество не может быть отрицательным'); return; } try { const res = await fetch('/api/lab9/products', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: newProduct.title.trim(), price: parsePrice(newProduct.price), amount: newProduct.amount }) }); if (!res.ok) { const errorData = await res.json().catch(() => ({})); throw new Error(errorData.error || `Ошибка ${res.status}`); } showNotification('✅ Новый товар успешно добавлен', 'success'); loadAllProducts(); loadStockStats(); } catch (error) { console.error('Ошибка добавления товара:', error); showError(`Не удалось добавить товар: ${error.message}`); } } document.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); if (Object.keys(pendingChanges).length > 0) { saveAllChanges(); } } if ((e.ctrlKey || e.metaKey) && e.key === 'z') { e.preventDefault(); cancelAllChanges(); } }); document.addEventListener('DOMContentLoaded', () => { const container = document.querySelector('.container'); const cancelAllBtn = document.createElement('button'); cancelAllBtn.className = 'cancel-all-btn'; cancelAllBtn.innerHTML = '↩️ Отменить все изменения'; cancelAllBtn.onclick = cancelAllChanges; cancelAllBtn.style.marginBottom = '10px'; cancelAllBtn.disabled = true; const addProductBtn = document.createElement('button'); addProductBtn.className = 'add-product-btn'; addProductBtn.innerHTML = '➕ Добавить товар'; addProductBtn.onclick = addNewProduct; addProductBtn.style.marginBottom = '10px'; addProductBtn.style.marginRight = '10px'; const buttonContainer = document.createElement('div'); buttonContainer.style.display = 'flex'; buttonContainer.style.gap = '10px'; buttonContainer.style.marginBottom = '15px'; buttonContainer.appendChild(addProductBtn); buttonContainer.appendChild(cancelAllBtn); const statsElement = document.getElementById('stockStats'); statsElement.parentNode.insertBefore(buttonContainer, statsElement); const observer = new MutationObserver(() => { cancelAllBtn.disabled = Object.keys(pendingChanges).length === 0; }); observer.observe(document.getElementById('productsBody'), { childList: true, subtree: true }); loadStockStats(); loadAllProducts(); setTimeout(() => { showNotification('💡 Горячие клавиши: Ctrl+S — сохранить, Ctrl+Z — отменить все', 'info'); }, 2000); });