| import flask |
| from flask import Flask, render_template_string, request, redirect, url_for, jsonify, send_from_directory |
| import json |
| import os |
| import logging |
| import threading |
| import time |
| import uuid |
| from datetime import datetime |
| from huggingface_hub import HfApi, hf_hub_download, list_repo_files |
| from huggingface_hub.utils import RepositoryNotFoundError, EntryNotFoundError |
| from werkzeug.utils import secure_filename |
| import io |
| import urllib.parse |
|
|
| app = Flask(__name__) |
| DATA_FILE = 'data_360_panorama.json' |
| PANORAMA_UPLOADS = 'uploads/panoramas' |
| PRODUCT_PHOTO_UPLOADS = 'uploads/product_photos' |
| os.makedirs(PANORAMA_UPLOADS, exist_ok=True) |
| os.makedirs(PRODUCT_PHOTO_UPLOADS, exist_ok=True) |
|
|
| REPO_ID = "Kgshop/360testb" |
| HF_TOKEN_WRITE = os.getenv("HF_TOKEN") |
| HF_TOKEN_READ = os.getenv("HF_TOKEN_READ", HF_TOKEN_WRITE) |
|
|
| logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') |
|
|
| app_data = {'points': [], 'categories': [], 'standalone_products': [], 'whatsapp_number': ''} |
|
|
| def generate_id(): |
| return str(uuid.uuid4()) |
|
|
| def get_hf_image_url(repo_id, file_path_in_repo): |
| if not file_path_in_repo: |
| return None |
| if file_path_in_repo.startswith('/'): |
| file_path_in_repo = file_path_in_repo[1:] |
| return f"https://huggingface.co/datasets/{repo_id}/resolve/main/{file_path_in_repo}" |
|
|
| def upload_file_to_hf(local_path, path_in_repo, repo_id, token, commit_message): |
| if not token: |
| logging.error("Hugging Face write token (HF_TOKEN) не установлен. Загрузка пропущена.") |
| return False |
| try: |
| api = HfApi() |
| api.upload_file( |
| path_or_fileobj=local_path, |
| path_in_repo=path_in_repo, |
| repo_id=repo_id, |
| repo_type="dataset", |
| token=token, |
| commit_message=commit_message |
| ) |
| logging.info(f"Успешно загружен {local_path} в {repo_id}/{path_in_repo}") |
| return True |
| except Exception as e: |
| logging.error(f"Ошибка загрузки {local_path} в Hugging Face: {e}") |
| return False |
|
|
| def delete_file_from_hf(path_in_repo, repo_id, token, commit_message): |
| if not token: |
| logging.error("Hugging Face write token (HF_TOKEN) не установлен. Удаление пропущено.") |
| return False |
| if not path_in_repo: |
| logging.warning("Попытка удалить файл из HF с пустым путем.") |
| return True |
| try: |
| api = HfApi() |
| api.delete_file( |
| path_in_repo=path_in_repo, |
| repo_id=repo_id, |
| repo_type="dataset", |
| token=token, |
| commit_message=commit_message |
| ) |
| logging.info(f"Успешно удален {path_in_repo} из {repo_id}") |
| return True |
| except EntryNotFoundError: |
| logging.warning(f"Файл {path_in_repo} не найден в репозитории {repo_id} для удаления.") |
| return True |
| except Exception as e: |
| logging.error(f"Ошибка удаления {path_in_repo} из Hugging Face: {e}") |
| return False |
|
|
| def upload_db_to_hf(): |
| if not HF_TOKEN_WRITE: |
| logging.error("Hugging Face write token (HF_TOKEN) не установлен. Загрузка БД пропущена.") |
| return |
| try: |
| api = HfApi() |
| db_content = json.dumps(app_data, ensure_ascii=False, indent=4).encode('utf-8') |
| db_stream = io.BytesIO(db_content) |
|
|
| api.upload_file( |
| path_or_fileobj=db_stream, |
| path_in_repo=DATA_FILE, |
| repo_id=REPO_ID, |
| repo_type="dataset", |
| token=HF_TOKEN_WRITE, |
| commit_message=f"Резервная копия БД {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" |
| ) |
| logging.info(f"База данных {DATA_FILE} успешно загружена в Hugging Face.") |
| except Exception as e: |
| logging.error(f"Ошибка загрузки базы данных в Hugging Face: {e}") |
|
|
| def download_db_from_hf(): |
| global app_data |
| default_data = {'points': [], 'categories': [], 'standalone_products': [], 'whatsapp_number': ''} |
| if not HF_TOKEN_READ: |
| logging.error("Hugging Face read token не установлен. Невозможно скачать БД.") |
| try: |
| with open(DATA_FILE, 'r', encoding='utf-8') as f: |
| app_data = json.load(f) |
| logging.info(f"Загружены данные из локального файла {DATA_FILE} из-за отсутствия read token.") |
| app_data.setdefault('standalone_products', []) |
| app_data.setdefault('whatsapp_number', '') |
| except FileNotFoundError: |
| logging.warning(f"Локальный файл {DATA_FILE} не найден и read token отсутствует. Запуск с пустыми данными.") |
| app_data = default_data |
| except json.JSONDecodeError: |
| logging.error(f"Ошибка декодирования локального JSON файла {DATA_FILE}. Запуск с пустыми данными.") |
| app_data = default_data |
| return |
|
|
| try: |
| local_path = hf_hub_download( |
| repo_id=REPO_ID, |
| filename=DATA_FILE, |
| repo_type="dataset", |
| token=HF_TOKEN_READ, |
| local_dir=".", |
| local_dir_use_symlinks=False, |
| force_download=True, |
| resume_download=False |
| ) |
| with open(local_path, 'r', encoding='utf-8') as file: |
| app_data = json.load(file) |
| if not isinstance(app_data, dict): |
| raise ValueError("Данные не являются словарем") |
| if 'points' not in app_data or not isinstance(app_data['points'], list): |
| app_data['points'] = [] |
| logging.warning("Отсутствует или неверный ключ 'points' в загруженных данных, инициализирован.") |
| if 'categories' not in app_data or not isinstance(app_data['categories'], list): |
| app_data['categories'] = [] |
| logging.warning("Отсутствует или неверный ключ 'categories' в загруженных данных, инициализирован.") |
| if 'standalone_products' not in app_data or not isinstance(app_data['standalone_products'], list): |
| app_data['standalone_products'] = [] |
| logging.warning("Отсутствует или неверный ключ 'standalone_products' в загруженных данных, инициализирован.") |
| if 'whatsapp_number' not in app_data: |
| app_data['whatsapp_number'] = '' |
| logging.warning("Отсутствует ключ 'whatsapp_number' в загруженных данных, инициализирован.") |
|
|
| logging.info(f"База данных {DATA_FILE} успешно скачана и загружена из Hugging Face.") |
|
|
| except RepositoryNotFoundError: |
| logging.error(f"Репозиторий Hugging Face {REPO_ID} не найден. Запуск с пустыми данными.") |
| app_data = default_data |
| except EntryNotFoundError: |
| logging.warning(f"Файл базы данных {DATA_FILE} не найден в репозитории {REPO_ID}. Запуск с пустыми данными.") |
| app_data = default_data |
| except json.JSONDecodeError: |
| logging.error(f"Ошибка декодирования JSON из скачанного файла {DATA_FILE}. Запуск с пустыми данными.") |
| app_data = default_data |
| except Exception as e: |
| logging.error(f"Произошла непредвиденная ошибка при скачивании базы данных: {e}") |
| app_data = default_data |
|
|
| def save_data(): |
| global app_data |
| try: |
| app_data.setdefault('points', []) |
| app_data.setdefault('categories', []) |
| app_data.setdefault('standalone_products', []) |
| app_data.setdefault('whatsapp_number', '') |
| with open(DATA_FILE, 'w', encoding='utf-8') as file: |
| json.dump(app_data, file, ensure_ascii=False, indent=4) |
| logging.info(f"Данные успешно сохранены локально в {DATA_FILE}") |
| upload_db_to_hf() |
| except Exception as e: |
| logging.error(f"Ошибка сохранения данных: {e}") |
|
|
| def periodic_backup(): |
| while True: |
| time.sleep(800) |
| logging.info("Выполнение периодического резервного копирования базы данных...") |
| save_data() |
|
|
| def find_point(point_id): |
| return next((p for p in app_data.get('points', []) if p.get('id') == point_id), None) |
|
|
| def find_category(category_id): |
| return next((c for c in app_data.get('categories', []) if c.get('id') == category_id), None) |
|
|
| def find_product_in_category(category_id, product_id): |
| category = find_category(category_id) |
| if category: |
| return next((p for p in category.get('products', []) if p.get('id') == product_id), None) |
| return None |
|
|
| def find_standalone_product(product_id): |
| return next((p for p in app_data.get('standalone_products', []) if p.get('id') == product_id), None) |
|
|
| def delete_product_files_from_hf(product): |
| hf_delete_failed = False |
| product_name_for_log = product.get('name', product.get('id', 'N/A')) |
| photo_paths = product.get('photo_paths', []) |
| for hf_path in photo_paths: |
| commit_msg = f"Удалить фото {os.path.basename(hf_path)} для удаленного товара {product_name_for_log}" |
| if not delete_file_from_hf(hf_path, REPO_ID, HF_TOKEN_WRITE, commit_msg): |
| logging.warning(f"Не удалось удалить фото товара {hf_path} из Hugging Face для товара {product_name_for_log}.") |
| hf_delete_failed = True |
| return hf_delete_failed |
|
|
| def cleanup_local_product_files(product): |
| photo_paths = product.get('photo_paths', []) |
| for hf_path in photo_paths: |
| local_filename = os.path.basename(hf_path) |
| local_path = os.path.join(PRODUCT_PHOTO_UPLOADS, local_filename) |
| if os.path.exists(local_path): |
| try: |
| os.remove(local_path) |
| except OSError as e: |
| logging.warning(f"Не удалось удалить остаточный локальный файл фото товара {local_path}: {e}") |
|
|
| @app.route('/') |
| def index(): |
| points_for_frontend = [] |
| for point in app_data.get('points', []): |
| hf_panorama_url = get_hf_image_url(REPO_ID, point.get('panorama_image_path', '')) |
| hotspots_for_frontend = [] |
| for hs in point.get('hotspots', []): |
| hotspots_for_frontend.append({ |
| 'id': hs.get('id'), |
| 'pitch': hs.get('pitch'), |
| 'yaw': hs.get('yaw'), |
| 'type': hs.get('type'), |
| 'target': hs.get('target'), |
| 'text': hs.get('text', 'Инфо'), |
| 'cssClass': 'custom-hotspot-button', |
| }) |
| points_for_frontend.append({ |
| 'id': point.get('id'), |
| 'name': point.get('name'), |
| 'panoramaUrl': hf_panorama_url, |
| 'hotspots': hotspots_for_frontend |
| }) |
|
|
| index_html = """ |
| <!DOCTYPE html> |
| <html lang="ru"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>360 Панорамный Каталог</title> |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/pannellum@2.5.6/build/pannellum.css"/> |
| <style> |
| html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; font-family: Arial, sans-serif; } |
| #panorama-container { width: 100%; height: 100%; } |
| .custom-hotspot-button-wrapper { z-index: 10; } |
| .custom-hotspot-button { |
| background-color: rgba(0, 123, 255, 0.8); |
| color: white; |
| padding: 8px 15px; |
| border-radius: 20px; |
| border: 1px solid rgba(255, 255, 255, 0.7); |
| font-size: 14px; |
| font-weight: bold; |
| cursor: pointer; |
| text-align: center; |
| box-shadow: 0 2px 5px rgba(0,0,0,0.3); |
| transition: background-color 0.3s ease, transform 0.2s ease; |
| white-space: nowrap; |
| display: block; |
| } |
| .custom-hotspot-button:hover { |
| background-color: rgba(0, 86, 179, 0.9); |
| transform: scale(1.05); |
| } |
| .modal { |
| display: none; position: fixed; z-index: 1001; left: 0; top: 0; |
| width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.7); |
| align-items: center; justify-content: center; |
| } |
| .modal-content { |
| background-color: #f8f9fa; margin: auto; padding: 25px; border: 1px solid #ccc; |
| width: 85%; max-width: 800px; border-radius: 15px; max-height: 85vh; overflow-y: auto; |
| box-shadow: 0 5px 15px rgba(0,0,0,0.2); position: relative; |
| } |
| .modal-close { |
| color: #aaa; position: absolute; top: 10px; right: 20px; |
| font-size: 32px; font-weight: bold; cursor: pointer; line-height: 1; |
| } |
| .modal-close:hover, .modal-close:focus { color: black; text-decoration: none; cursor: pointer; } |
| .product-list { display: flex; flex-direction: column; gap: 20px; margin-top: 20px;} |
| .product-item { |
| display: flex; gap: 15px; border: 1px solid #ddd; padding: 15px; |
| background-color: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); |
| } |
| .product-item img { |
| width: 120px; height: 120px; object-fit: cover; border-radius: 5px; flex-shrink: 0; |
| } |
| .product-details { flex-grow: 1; } |
| .product-item h4 { font-size: 1.1em; margin-bottom: 8px; color: #333; } |
| .product-item .price { font-weight: bold; color: #007bff; margin-top: 8px; font-size: 1.1em; } |
| .product-description { display: none; margin-top: 10px; font-size: 0.9em; color: #555; padding-top: 10px; border-top: 1px solid #eee; } |
| .product-actions button { |
| background-color: #007bff; color: white; padding: 6px 12px; border: none; border-radius: 4px; cursor: pointer; font-size: 0.9em; margin-right: 5px; margin-top: 10px; |
| } |
| .product-actions button:hover { background-color: #0056b3; } |
| .product-actions .details-button { background-color: #6c757d; } |
| .product-actions .details-button:hover { background-color: #5a6268; } |
| .no-image-placeholder { |
| width:120px; height:120px; background:#eee; display:flex; align-items:center; justify-content:center; color:#aaa; border-radius:5px; flex-shrink:0; font-size: 12px; text-align: center; |
| } |
| #single-product-modal .product-item img { |
| width: 150px; height: 150px; |
| } |
| #single-product-modal .product-description { |
| display: block; |
| } |
| #floating-cart-button { |
| position: fixed; bottom: 20px; right: 20px; background-color: #dc3545; |
| color: white; padding: 12px 20px; border-radius: 50px; font-size: 16px; |
| font-weight: bold; cursor: pointer; box-shadow: 0 4px 8px rgba(0,0,0,0.3); |
| z-index: 1005; display: none; transition: transform 0.2s ease-in-out; |
| } |
| #floating-cart-button:hover { transform: scale(1.1); } |
| #cart-modal .modal-content { max-width: 600px; } |
| .cart-item { display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-bottom: 1px solid #eee; } |
| .cart-item:last-child { border-bottom: none; } |
| .cart-item-details { flex-grow: 1; margin-right: 10px; } |
| .cart-item-name { font-weight: bold; } |
| .cart-item-price { color: #555; font-size: 0.9em; } |
| .cart-item-quantity input { width: 50px; text-align: center; margin: 0 5px; } |
| .cart-total { text-align: right; font-size: 1.2em; font-weight: bold; margin-top: 20px; padding-top: 15px; border-top: 2px solid #333;} |
| .cart-actions button { margin-left: 10px; } |
| .remove-item-btn { background-color: #dc3545; } |
| .remove-item-btn:hover { background-color: #c82333; } |
| .quantity-input { width: 40px; padding: 5px; text-align: center; border-radius: 4px; border: 1px solid #ccc; margin-left: 5px; } |
| </style> |
| </head> |
| <body> |
| <div id="panorama-container"></div> |
| <div id="product-modal" class="modal"> |
| <div class="modal-content"> |
| <span class="modal-close" onclick="closeModal('product-modal')">×</span> |
| <h2 id="modal-category-name">Товары Категории</h2> |
| <div id="modal-product-list" class="product-list"> |
| </div> |
| </div> |
| </div> |
| <div id="single-product-modal" class="modal"> |
| <div class="modal-content"> |
| <span class="modal-close" onclick="closeModal('single-product-modal')">×</span> |
| <h2 id="single-product-modal-title">Информация о Товаре</h2> |
| <div id="single-product-content"> |
| </div> |
| </div> |
| </div> |
| <div id="cart-modal" class="modal"> |
| <div class="modal-content"> |
| <span class="modal-close" onclick="closeModal('cart-modal')">×</span> |
| <h2>Ваша Корзина</h2> |
| <div id="cart-items-list"> |
| </div> |
| <div id="cart-total" class="cart-total"> |
| Итого: 0 SOM |
| </div> |
| <div style="text-align: right; margin-top: 20px;"> |
| <button class="button" onclick="openWhatsAppOrder()">Оформить заказ</button> |
| <button class="delete-button" onclick="clearCart()">Очистить корзину</button> |
| </div> |
| </div> |
| </div> |
| <div id="floating-cart-button" onclick="showCartModal()">Корзина (0)</div> |
| |
| <script src="https://cdn.jsdelivr.net/npm/pannellum@2.5.6/build/pannellum.js"></script> |
| <script> |
| const pointsData = {{ points_data | tojson }}; |
| const whatsappNumber = "{{ whatsapp_number }}"; |
| let currentViewer = null; |
| const panoramaContainer = document.getElementById('panorama-container'); |
| const floatingCartButton = document.getElementById('floating-cart-button'); |
| |
| function getModal(modalId) { return document.getElementById(modalId); } |
| function closeModal(modalId) { getModal(modalId).style.display = 'none'; } |
| function showModal(modalId) { getModal(modalId).style.display = 'flex'; } |
| |
| function createHotspotElement(hotSpotDiv, args) { |
| hotSpotDiv.classList.add('custom-hotspot-button-wrapper'); |
| const button = document.createElement('button'); |
| button.classList.add('custom-hotspot-button'); |
| button.textContent = args.text || 'Инфо'; |
| hotSpotDiv.style.pointerEvents = 'auto'; |
| button.style.pointerEvents = 'auto'; |
| button.onclick = (e) => { |
| e.stopPropagation(); |
| handleHotspotClick(args); |
| }; |
| hotSpotDiv.appendChild(button); |
| } |
| |
| function toggleProductDescription(productId, modalId = 'product-modal') { |
| const descriptionDiv = getModal(modalId).querySelector(`#desc-${productId}`); |
| if (descriptionDiv) { |
| descriptionDiv.style.display = descriptionDiv.style.display === 'block' ? 'none' : 'block'; |
| } |
| } |
| |
| function renderProductItem(product, isSingleView = false) { |
| let imgHtml = product.photoUrl |
| ? `<img src="${product.photoUrl}" alt="${product.name}">` |
| : '<div class="no-image-placeholder">Нет Изображения</div>'; |
| |
| const descriptionStyle = isSingleView ? 'style="display: block;"' : ''; |
| |
| return ` |
| <div class="product-item" id="product-${product.id}"> |
| ${imgHtml} |
| <div class="product-details"> |
| <h4>${product.name}</h4> |
| <p class="price">${product.price} SOM</p> |
| <div class="product-actions"> |
| ${!isSingleView ? `<button class="details-button" onclick="toggleProductDescription('${product.id}', 'product-modal')">Детали</button>` : ''} |
| <button onclick="addToCart('${product.id}', '${product.name}', ${product.price}, document.getElementById('quantity-${product.id}').value)">В корзину</button> |
| <input type="number" id="quantity-${product.id}" class="quantity-input" value="1" min="1"> |
| </div> |
| <div class="product-description" id="desc-${product.id}" ${descriptionStyle}> |
| <p>${product.description || 'Описание отсутствует.'}</p> |
| </div> |
| </div> |
| </div>`; |
| } |
| |
| function showCategoryProductModal(categoryName, products) { |
| const modalList = getModal('product-modal').querySelector('#modal-product-list'); |
| getModal('product-modal').querySelector('#modal-category-name').textContent = categoryName || 'Товары Категории'; |
| modalList.innerHTML = ''; |
| |
| if (products && products.length > 0) { |
| products.forEach(product => { |
| modalList.innerHTML += renderProductItem(product); |
| }); |
| } else { |
| modalList.innerHTML = '<p>Товары в этой категории не найдены.</p>'; |
| } |
| showModal('product-modal'); |
| } |
| |
| function showSingleProductModal(product) { |
| const modalContent = getModal('single-product-modal').querySelector('#single-product-content'); |
| getModal('single-product-modal').querySelector('#single-product-modal-title').textContent = product.name || 'Информация о Товаре'; |
| modalContent.innerHTML = renderProductItem(product, true); |
| showModal('single-product-modal'); |
| } |
| |
| function getCart() { |
| const cart = localStorage.getItem('shoppingCart'); |
| return cart ? JSON.parse(cart) : []; |
| } |
| |
| function saveCart(cart) { |
| localStorage.setItem('shoppingCart', JSON.stringify(cart)); |
| updateCartButton(); |
| renderCartItems(); |
| } |
| |
| function addToCart(productId, name, price, quantity) { |
| const cart = getCart(); |
| const existingItemIndex = cart.findIndex(item => item.id === productId); |
| const qty = parseInt(quantity, 10); |
| |
| if (existingItemIndex > -1) { |
| cart[existingItemIndex].quantity += qty; |
| } else { |
| cart.push({ id: productId, name: name, price: price, quantity: qty }); |
| } |
| saveCart(cart); |
| alert(`"${name}" добавлен в корзину в количестве ${qty} шт.!`); |
| } |
| |
| function updateCartQuantity(productId, change) { |
| const cart = getCart(); |
| const itemIndex = cart.findIndex(item => item.id === productId); |
| if (itemIndex > -1) { |
| cart[itemIndex].quantity += change; |
| if (cart[itemIndex].quantity <= 0) { |
| cart.splice(itemIndex, 1); |
| } |
| saveCart(cart); |
| } |
| } |
| |
| function removeFromCart(productId) { |
| let cart = getCart(); |
| cart = cart.filter(item => item.id !== productId); |
| saveCart(cart); |
| } |
| |
| function clearCart() { |
| if (confirm('Вы уверены, что хотите очистить корзину?')) { |
| saveCart([]); |
| closeModal('cart-modal'); |
| } |
| } |
| |
| function updateCartButton() { |
| const cart = getCart(); |
| const totalItems = cart.reduce((sum, item) => sum + item.quantity, 0); |
| |
| if (totalItems > 0) { |
| floatingCartButton.textContent = `Корзина (${totalItems})`; |
| floatingCartButton.style.display = 'block'; |
| } else { |
| floatingCartButton.style.display = 'none'; |
| } |
| } |
| |
| function renderCartItems() { |
| const cart = getCart(); |
| const cartListDiv = getModal('cart-modal').querySelector('#cart-items-list'); |
| const cartTotalDiv = getModal('cart-modal').querySelector('#cart-total'); |
| let totalAmount = 0; |
| cartListDiv.innerHTML = ''; |
| |
| if (cart.length === 0) { |
| cartListDiv.innerHTML = '<p>Ваша корзина пуста.</p>'; |
| cartTotalDiv.textContent = 'Итого: 0 SOM'; |
| return; |
| } |
| |
| cart.forEach(item => { |
| const itemTotal = item.price * item.quantity; |
| totalAmount += itemTotal; |
| const itemDiv = document.createElement('div'); |
| itemDiv.classList.add('cart-item'); |
| itemDiv.innerHTML = ` |
| <div class="cart-item-details"> |
| <div class="cart-item-name">${item.name}</div> |
| <div class="cart-item-price">${item.quantity} x ${item.price} SOM = ${itemTotal.toFixed(2)} SOM</div> |
| </div> |
| <div class="cart-item-quantity"> |
| <button onclick="updateCartQuantity('${item.id}', -1)" class="cart-actions">-</button> |
| <span>${item.quantity}</span> |
| <button onclick="updateCartQuantity('${item.id}', 1)" class="cart-actions">+</button> |
| </div> |
| <button class="cart-actions remove-item-btn" onclick="removeFromCart('${item.id}')">Удалить</button> |
| `; |
| cartListDiv.appendChild(itemDiv); |
| }); |
| |
| cartTotalDiv.textContent = `Итого: ${totalAmount.toFixed(2)} SOM`; |
| } |
| |
| function showCartModal() { |
| renderCartItems(); |
| showModal('cart-modal'); |
| } |
| |
| function openWhatsAppOrder() { |
| const cart = getCart(); |
| if (cart.length === 0) { |
| alert('Корзина пуста. Добавьте товары для оформления заказа.'); |
| return; |
| } |
| |
| let orderMessage = "Здравствуйте! Я хочу оформить заказ:\\n"; |
| let totalSum = 0; |
| cart.forEach(item => { |
| const itemTotal = item.price * item.quantity; |
| totalSum += itemTotal; |
| orderMessage += `- ${item.name} - ${item.quantity} шт. (Цена за шт: ${item.price} SOM, Итого: ${itemTotal.toFixed(2)} SOM)\\n`; |
| }); |
| orderMessage += `\\nОбщая сумма заказа: ${totalSum.toFixed(2)} SOM`; |
| |
| const whatsappUrl = `https://wa.me/${whatsappNumber}?text=${encodeURIComponent(orderMessage)}`; |
| window.open(whatsappUrl, '_blank'); |
| } |
| |
| function handleHotspotClick(args) { |
| console.log("Хотспот нажат:", args); |
| if (!args || !args.type) { |
| console.warn("Нажатый хотспот не имеет типа в аргументах"); |
| return; |
| } |
| |
| switch(args.type) { |
| case 'point': |
| if (args.target && pointsData.find(p => p.id === args.target)) { |
| loadPanorama(args.target); |
| } else { |
| console.error(`Целевой поинт ID ${args.target} не найден.`); |
| alert("Ошибка: Связанная панорама не найдена."); |
| } |
| break; |
| case 'link': |
| if (args.target) { |
| window.open(args.target, '_blank'); |
| } else { |
| console.error("Хотспот-ссылка не имеет целевого URL."); |
| alert("Ошибка: URL ссылки отсутствует."); |
| } |
| break; |
| case 'category': |
| if (args.target) { |
| fetch(`/category_products/${args.target}`) |
| .then(response => { |
| if (!response.ok) throw new Error(`HTTP ошибка! Статус: ${response.status}`); |
| return response.json(); |
| }) |
| .then(data => { |
| showCategoryProductModal(data.category_name, data.products); |
| }) |
| .catch(error => { |
| console.error('Ошибка загрузки товаров категории:', error); |
| alert(`Ошибка загрузки товаров: ${error.message}`); |
| showCategoryProductModal('Ошибка', []); |
| }); |
| } else { |
| console.error("Хотспот категории не имеет ID целевой категории."); |
| alert("Ошибка: Ссылка на категорию повреждена."); |
| } |
| break; |
| case 'product': |
| if (args.target) { |
| fetch(`/standalone_product_details/${args.target}`) |
| .then(response => { |
| if (!response.ok) throw new Error(`HTTP ошибка! Статус: ${response.status}`); |
| return response.json(); |
| }) |
| .then(data => { |
| if (data.error) { |
| throw new Error(data.error); |
| } |
| showSingleProductModal(data.product); |
| }) |
| .catch(error => { |
| console.error('Ошибка загрузки информации о товаре:', error); |
| alert(`Ошибка загрузки товара: ${error.message}`); |
| }); |
| } else { |
| console.error("Хотспот товара не имеет ID целевого товара."); |
| alert("Ошибка: Ссылка на товар повреждена."); |
| } |
| break; |
| default: |
| console.warn(`Неизвестный тип хотспота: ${args.type}`); |
| } |
| } |
| |
| function loadPanorama(pointId) { |
| const point = pointsData.find(p => p.id === pointId); |
| if (!point) { |
| console.error(`Поинт с ID ${pointId} не найден в pointsData.`); |
| if(pointsData.length > 0 && pointId !== pointsData[0].id) { |
| loadPanorama(pointsData[0].id); |
| } else { |
| panoramaContainer.innerHTML = '<p style="text-align: center; padding-top: 50px;">Ошибка: Данные панорамы не найдены.</p>'; |
| } |
| return; |
| } |
| |
| const configuredHotspots = point.hotspots.map(hs => ({ |
| pitch: hs.pitch, |
| yaw: hs.yaw, |
| createTooltipFunc: createHotspotElement, |
| createTooltipArgs: { type: hs.type, target: hs.target, text: hs.text } |
| })); |
| |
| if (currentViewer) { |
| try { |
| currentViewer.destroy(); |
| } catch (e) { |
| console.warn("Ошибка уничтожения предыдущего экземпляра Pannellum:", e); |
| } |
| } |
| |
| try { |
| currentViewer = pannellum.viewer('panorama-container', { |
| "type": "equirectangular", |
| "panorama": point.panoramaUrl, |
| "autoLoad": true, |
| "autoRotate": -2, |
| "hfov": 90, |
| "pitch": 0, |
| "yaw": 0, |
| "hotSpots": configuredHotspots, |
| "strings": { |
| "loadButtonLabel": "Нажмите для загрузки", |
| "loadingLabel": "Загрузка...", |
| "fullscreenNotSupported": "Полноэкранный режим не поддерживается", |
| "errorLoading": "Ошибка загрузки панорамы." |
| } |
| }); |
| currentViewer.on('error', function(err) { |
| console.error('Ошибка Pannellum viewer:', err); |
| panoramaContainer.innerHTML = `<p style="text-align: center; padding-top: 50px;">Ошибка загрузки панорамы: ${err}</p>`; |
| }); |
| currentViewer.on('load', function() { |
| console.log(`Панорама загружена: ${point.name}`); |
| }); |
| |
| } catch (e) { |
| console.error("Не удалось инициализировать Pannellum viewer:", e); |
| panoramaContainer.innerHTML = '<p style="text-align: center; padding-top: 50px;">Не удалось инициализировать просмотрщик панорам.</p>'; |
| } |
| } |
| |
| document.addEventListener('DOMContentLoaded', () => { |
| if (pointsData && pointsData.length > 0) { |
| loadPanorama(pointsData[0].id); |
| } else { |
| console.warn("Данные поинтов панорамы не найдены."); |
| panoramaContainer.innerHTML = '<p style="text-align: center; padding-top: 50px;">Панорамы отсутствуют. Пожалуйста, добавьте их в админ-панели.</p>'; |
| } |
| updateCartButton(); |
| }); |
| |
| window.onclick = function(event) { |
| if (event.target.classList.contains('modal')) { |
| closeModal(event.target.id); |
| } |
| } |
| </script> |
| </body> |
| </html> |
| """ |
| return render_template_string(index_html, points_data=points_for_frontend, whatsapp_number=app_data.get('whatsapp_number', '')) |
|
|
| @app.route('/category_products/<category_id>') |
| def get_category_products(category_id): |
| category = find_category(category_id) |
| if not category: |
| return jsonify({"error": "Категория не найдена"}), 404 |
|
|
| products_with_urls = [] |
| for product in category.get('products', []): |
| photo_url = None |
| if product.get('photo_paths') and len(product['photo_paths']) > 0: |
| photo_url = get_hf_image_url(REPO_ID, product['photo_paths'][0]) |
|
|
| products_with_urls.append({ |
| "id": product.get('id'), |
| "name": product.get('name', 'N/A'), |
| "price": product.get('price', 0), |
| "description": product.get('description', ''), |
| "photoUrl": photo_url |
| }) |
|
|
| return jsonify({ |
| "category_id": category_id, |
| "category_name": category.get('name', 'Неизвестная Категория'), |
| "products": products_with_urls |
| }) |
|
|
| @app.route('/standalone_product_details/<product_id>') |
| def get_standalone_product_details(product_id): |
| product = find_standalone_product(product_id) |
| if not product: |
| return jsonify({"error": "Товар не найден"}), 404 |
|
|
| photo_url = None |
| if product.get('photo_paths') and len(product['photo_paths']) > 0: |
| photo_url = get_hf_image_url(REPO_ID, product['photo_paths'][0]) |
|
|
| product_details = { |
| "id": product.get('id'), |
| "name": product.get('name', 'N/A'), |
| "price": product.get('price', 0), |
| "description": product.get('description', ''), |
| "photoUrl": photo_url, |
| "allPhotoUrls": [get_hf_image_url(REPO_ID, p) for p in product.get('photo_paths', []) if p] |
| } |
|
|
| return jsonify({"product": product_details}) |
|
|
| @app.route('/admin', methods=['GET']) |
| def admin_panel(): |
| admin_html = """ |
| <!DOCTYPE html> |
| <html lang="ru"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Админ-панель</title> |
| <style> |
| body { font-family: sans-serif; padding: 20px; background-color: #f4f4f4; } |
| .container { max-width: 1200px; margin: auto; background: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.1); } |
| h1, h2, h3 { color: #333; border-bottom: 1px solid #eee; padding-bottom: 10px; margin-bottom: 20px; } |
| .section { margin-bottom: 30px; padding: 20px; border: 1px solid #ddd; border-radius: 5px; background-color: #fafafa; } |
| label { display: block; margin-bottom: 5px; font-weight: bold; } |
| input[type=text], input[type=number], input[type=url], textarea, select { |
| width: calc(100% - 22px); padding: 10px; margin-bottom: 15px; border: 1px solid #ccc; border-radius: 4px; |
| } |
| input[type=file] { margin-bottom: 15px; } |
| button, .button { |
| background-color: #5cb85c; color: white; padding: 10px 15px; border: none; border-radius: 4px; |
| cursor: pointer; text-decoration: none; display: inline-block; margin-right: 5px; |
| } |
| button:hover, .button:hover { background-color: #4cae4c; } |
| .delete-button { background-color: #d9534f; } |
| .delete-button:hover { background-color: #c9302c; } |
| .edit-button { background-color: #f0ad4e; } |
| .edit-button:hover { background-color: #ec971f; } |
| .view-button { background-color: #5bc0de; } |
| .view-button:hover { background-color: #46b8da; } |
| ul { list-style: none; padding: 0; } |
| li { background: #fff; border: 1px solid #eee; margin-bottom: 10px; padding: 15px; border-radius: 4px; display: flex; justify-content: space-between; align-items: center; } |
| li span { flex-grow: 1; margin-right: 10px; } |
| .form-inline button { margin-top: 10px; } |
| .hidden { display: none; } |
| </style> |
| </head> |
| <body> |
| <div class="container"> |
| <h1>Админ-панель 360° Каталога</h1> |
| |
| <div class="section"> |
| <h2>Управление Поинтами (Панорамами)</h2> |
| <form action="/admin/points/add" method="post" enctype="multipart/form-data"> |
| <h3>Добавить новый Поинт</h3> |
| <label for="point_name">Название Поинта:</label> |
| <input type="text" id="point_name" name="point_name" required> |
| <label for="panorama_image">Файл панорамы (JPG):</label> |
| <input type="file" id="panorama_image" name="panorama_image" accept="image/jpeg, image/jpg" required> |
| <button type="submit">Добавить Поинт</button> |
| </form> |
| |
| <h3>Существующие Поинты</h3> |
| <ul id="points-list"> |
| {% for point in points %} |
| <li> |
| <span>{{ point.name }} (ID: {{ point.id }})</span> |
| <div> |
| <a href="{{ url_for('admin_edit_point', point_id=point.id) }}" class="button view-button">Редактировать Хотспоты</a> |
| <form action="/admin/points/delete/{{ point.id }}" method="post" style="display: inline;"> |
| <button type="submit" class="delete-button" onclick="return confirm('Вы уверены, что хотите удалить этот поинт и все его хотспоты?');">Удалить</button> |
| </form> |
| </div> |
| </li> |
| {% else %} |
| <p>Пока нет ни одного поинта.</p> |
| {% endfor %} |
| </ul> |
| </div> |
| |
| <div class="section"> |
| <h2>Управление Категориями</h2> |
| <form action="/admin/categories/add" method="post"> |
| <h3>Добавить новую Категорию</h3> |
| <label for="category_name">Название Категории:</label> |
| <input type="text" id="category_name" name="category_name" required> |
| <button type="submit">Добавить Категорию</button> |
| </form> |
| |
| <h3>Существующие Категории</h3> |
| <ul id="categories-list"> |
| {% for category in categories %} |
| <li> |
| <span>{{ category.name }} (ID: {{ category.id }})</span> |
| <div> |
| <a href="{{ url_for('admin_manage_category_products', category_id=category.id) }}" class="button view-button">Управлять Товарами</a> |
| <form action="/admin/categories/delete/{{ category.id }}" method="post" style="display: inline;"> |
| <button type="submit" class="delete-button" onclick="return confirm('Вы уверены, что хотите удалить эту категорию и ВСЕ товары в ней? Это действие необратимо.');">Удалить</button> |
| </form> |
| </div> |
| </li> |
| {% else %} |
| <p>Пока нет ни одной категории.</p> |
| {% endfor %} |
| </ul> |
| </div> |
| |
| <div class="section"> |
| <h2>Управление Отдельными Товарами</h2> |
| <p>Эти товары можно будет выбирать напрямую при создании хотспота типа "Товар".</p> |
| <a href="{{ url_for('admin_manage_standalone_products') }}" class="button view-button">Перейти к управлению отдельными товарами</a> |
| </div> |
| |
| <div class="section"> |
| <h2>Настройки WhatsApp</h2> |
| <form action="{{ url_for('admin_save_whatsapp_number') }}" method="post"> |
| <label for="whatsapp_number">Номер WhatsApp для заказов:</label> |
| <input type="text" id="whatsapp_number" name="whatsapp_number" value="{{ whatsapp_number }}"> |
| <button type="submit">Сохранить номер WhatsApp</button> |
| </form> |
| </div> |
| |
| <div class="section"> |
| <h2>Резервное копирование</h2> |
| <p>База данных автоматически сохраняется на Hugging Face при каждом изменении и периодически.</p> |
| <form action="{{ url_for('manual_backup') }}" method="post" style="display: inline;"> |
| <button type="submit">Создать резервную копию БД сейчас</button> |
| </form> |
| <p><i>Примечание: Изображения (панорамы, фото товаров) загружаются на Hugging Face сразу при их добавлении/изменении.</i></p> |
| </div> |
| </div> |
| </body> |
| </html> |
| """ |
| return render_template_string(admin_html, |
| points=app_data.get('points', []), |
| categories=app_data.get('categories', []), |
| standalone_products=app_data.get('standalone_products', []), |
| whatsapp_number=app_data.get('whatsapp_number', '')) |
|
|
| @app.route('/admin/settings/whatsapp', methods=['POST']) |
| def admin_save_whatsapp_number(): |
| whatsapp_number = request.form.get('whatsapp_number') |
| app_data['whatsapp_number'] = whatsapp_number |
| save_data() |
| return redirect(url_for('admin_panel')) |
|
|
| @app.route('/admin/points/add', methods=['POST']) |
| def admin_add_point(): |
| point_name = request.form.get('point_name') |
| panorama_file = request.files.get('panorama_image') |
|
|
| if not point_name or not panorama_file or not panorama_file.filename: |
| return redirect(url_for('admin_panel')) |
|
|
| if panorama_file: |
| filename = secure_filename(panorama_file.filename) |
| unique_filename = f"{generate_id()}_{filename}" |
| local_path = os.path.join(PANORAMA_UPLOADS, unique_filename) |
| hf_path = f"panoramas/{unique_filename}" |
|
|
| try: |
| panorama_file.save(local_path) |
| logging.info(f"Панорама сохранена локально в {local_path}") |
|
|
| commit_msg = f"Добавить панораму для поинта: {point_name}" |
| if upload_file_to_hf(local_path, hf_path, REPO_ID, HF_TOKEN_WRITE, commit_msg): |
| new_point = { |
| "id": generate_id(), |
| "name": point_name, |
| "panorama_image_path": hf_path, |
| "hotspots": [] |
| } |
| app_data.setdefault('points', []).append(new_point) |
| save_data() |
| logging.info(f"Поинт '{point_name}' успешно добавлен.") |
| try: |
| os.remove(local_path) |
| logging.info(f"Удален временный локальный файл {local_path}") |
| except OSError as e: |
| logging.warning(f"Не удалось удалить временный локальный файл {local_path}: {e}") |
| else: |
| logging.error(f"Не удалось загрузить панораму для поинта {point_name} в Hugging Face. Поинт не добавлен.") |
| try: |
| os.remove(local_path) |
| except OSError: pass |
|
|
| except Exception as e: |
| logging.error(f"Ошибка обработки загрузки панорамы для {point_name}: {e}") |
|
|
| return redirect(url_for('admin_panel')) |
|
|
| @app.route('/admin/points/delete/<point_id>', methods=['POST']) |
| def admin_delete_point(point_id): |
| point_index = next((index for (index, p) in enumerate(app_data.get('points', [])) if p.get('id') == point_id), None) |
| if point_index is not None: |
| point = app_data['points'][point_index] |
| panorama_hf_path = point.get('panorama_image_path') |
|
|
| deleted_from_hf = False |
| if panorama_hf_path: |
| commit_msg = f"Удалить панораму для поинта: {point.get('name', point_id)}" |
| if delete_file_from_hf(panorama_hf_path, REPO_ID, HF_TOKEN_WRITE, commit_msg): |
| deleted_from_hf = True |
| else: |
| logging.warning(f"Не удалось удалить панораму {panorama_hf_path} из Hugging Face. Данные поинта все равно будут удалены.") |
|
|
| deleted_point = app_data['points'].pop(point_index) |
| save_data() |
| logging.info(f"Удален поинт: {deleted_point.get('name', point_id)}") |
|
|
| if panorama_hf_path: |
| local_filename = os.path.basename(panorama_hf_path) |
| local_path = os.path.join(PANORAMA_UPLOADS, local_filename) |
| if os.path.exists(local_path): |
| try: |
| os.remove(local_path) |
| except OSError as e: |
| logging.warning(f"Не удалось удалить остаточный локальный файл {local_path}: {e}") |
| else: |
| logging.warning(f"Попытка удалить несуществующий поинт с ID: {point_id}") |
|
|
| return redirect(url_for('admin_panel')) |
|
|
| @app.route('/admin/points/edit/<point_id>', methods=['GET']) |
| def admin_edit_point(point_id): |
| point = find_point(point_id) |
| if not point: |
| return redirect(url_for('admin_panel')) |
|
|
| panorama_url = get_hf_image_url(REPO_ID, point.get('panorama_image_path', '')) |
| all_points = [{"id": p['id'], "name": p['name']} for p in app_data.get('points', []) if p['id'] != point_id] |
| all_categories = [{"id": c['id'], "name": c['name']} for c in app_data.get('categories', [])] |
| all_standalone_products = [{"id": p['id'], "name": p['name']} for p in app_data.get('standalone_products', [])] |
|
|
| edit_point_html = """ |
| <!DOCTYPE html> |
| <html lang="ru"> |
| <head> |
| <meta charset="UTF-8"> |
| <title>Редактировать Поинт: {{ point.name }}</title> |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/pannellum@2.5.6/build/pannellum.css"/> |
| <style> |
| body { font-family: sans-serif; padding: 20px; background-color: #f4f4f4; } |
| .container { max-width: 1200px; margin: auto; background: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.1); } |
| h1, h2, h3 { color: #333; border-bottom: 1px solid #eee; padding-bottom: 10px; margin-bottom: 20px; } |
| .section { margin-bottom: 30px; padding: 20px; border: 1px solid #ddd; border-radius: 5px; background-color: #fafafa; } |
| label { display: block; margin-bottom: 5px; font-weight: bold; } |
| input[type=text], input[type=number], input[type=url], textarea, select { |
| width: calc(100% - 22px); padding: 10px; margin-bottom: 15px; border: 1px solid #ccc; border-radius: 4px; |
| } |
| button, .button { |
| background-color: #5cb85c; color: white; padding: 10px 15px; border: none; border-radius: 4px; |
| cursor: pointer; text-decoration: none; display: inline-block; margin-right: 5px; |
| } |
| button:hover, .button:hover { background-color: #4cae4c; } |
| .delete-button { background-color: #d9534f; } |
| .delete-button:hover { background-color: #c9302c; } |
| ul { list-style: none; padding: 0; } |
| li { background: #fff; border: 1px solid #eee; margin-bottom: 10px; padding: 15px; border-radius: 4px; display: flex; justify-content: space-between; align-items: center; } |
| li span { flex-grow: 1; margin-right: 10px; word-break: break-all; } |
| .hidden { display: none; } |
| #panorama-preview-container { width: 100%; height: 450px; border: 1px solid #ccc; margin-bottom: 15px; position: relative; background-color: #eee; cursor: crosshair; } |
| #panorama-preview-container::after { |
| content: 'Кликните на панораму для добавления хотспота в эту точку'; position: absolute; top: 10px; left: 10px; background: rgba(0,0,0,0.6); color: white; padding: 5px; border-radius: 3px; font-size: 12px; pointer-events: none; z-index: 10; |
| } |
| .hotspot-list-item { background-color: #e9e9e9; font-size: 0.9em;} |
| .hotspot-form-section { margin-top: 20px; padding: 15px; background-color: #eef; border-radius: 5px; } |
| .target-field { margin-top: 10px; padding-top: 10px; border-top: 1px dashed #ccc; } |
| </style> |
| </head> |
| <body> |
| <div class="container"> |
| <h1>Редактировать Поинт: {{ point.name }}</h1> |
| <a href="{{ url_for('admin_panel') }}" class="button">« Назад к админ-панели</a> |
| |
| <div class="section"> |
| <h2>Предпросмотр Панорамы и Хотспоты</h2> |
| <div id="panorama-preview-container"></div> |
| <p>Кликните на панораму, чтобы получить координаты (Pitch/Yaw) для нового хотспота.</p> |
| |
| <div id="hotspot-form-section" class="hotspot-form-section"> |
| <h3>Добавить Хотспот</h3> |
| <form id="hotspot-form" action="/admin/points/{{ point.id }}/hotspots/save" method="post"> |
| <input type="hidden" name="hotspot_id" id="hotspot_id"> |
| <label for="hotspot_pitch">Pitch (Вертикальный угол):</label> |
| <input type="number" step="any" id="hotspot_pitch" name="pitch" required readonly placeholder="Кликните на панораму"> |
| |
| <label for="hotspot_yaw">Yaw (Горизонтальный угол):</label> |
| <input type="number" step="any" id="hotspot_yaw" name="yaw" required readonly placeholder="Кликните на панораму"> |
| |
| <label for="hotspot_text">Текст для кнопки хотспота:</label> |
| <input type="text" id="hotspot_text" name="text" value="Подробнее" required> |
| |
| <label for="hotspot_type">Тип Хотспота:</label> |
| <select id="hotspot_type" name="type" required onchange="updateHotspotTarget()"> |
| <option value="">-- Выберите тип --</option> |
| <option value="point">Переход на другой Поинт</option> |
| <option value="link">Внешняя Ссылка</option> |
| <option value="category">Показать Категорию Товаров</option> |
| <option value="product">Показать Отдельный Товар</option> |
| </select> |
| |
| <div id="target-point" class="hidden target-field"> |
| <label for="target_point_id">Целевой Поинт:</label> |
| <select id="target_point_id" name="target_point_id"> |
| <option value="">-- Выберите поинт --</option> |
| {% for p in all_points %} |
| <option value="{{ p.id }}">{{ p.name }}</option> |
| {% endfor %} |
| </select> |
| </div> |
| <div id="target-link" class="hidden target-field"> |
| <label for="target_link_url">URL Ссылки:</label> |
| <input type="url" id="target_link_url" name="target_link_url" placeholder="https://example.com"> |
| </div> |
| <div id="target-category" class="hidden target-field"> |
| <label for="target_category_id">Категория Товаров:</label> |
| <select id="target_category_id" name="target_category_id"> |
| <option value="">-- Выберите категорию --</option> |
| {% for c in all_categories %} |
| <option value="{{ c.id }}">{{ c.name }}</option> |
| {% endfor %} |
| </select> |
| </div> |
| <div id="target-product" class="hidden target-field"> |
| <label for="target_product_id">Отдельный Товар:</label> |
| <select id="target_product_id" name="target_product_id"> |
| <option value="">-- Выберите товар --</option> |
| {% for p in all_standalone_products %} |
| <option value="{{ p.id }}">{{ p.name }}</option> |
| {% endfor %} |
| </select> |
| {% if not all_standalone_products %} |
| <small style="color: #888;">Нет отдельных товаров. <a href="{{ url_for('admin_manage_standalone_products') }}">Добавить?</a></small> |
| {% endif %} |
| </div> |
| |
| <button type="submit">Сохранить Хотспот</button> |
| <button type="button" onclick="clearHotspotForm()">Очистить форму</button> |
| </form> |
| </div> |
| |
| <h3>Существующие Хотспоты</h3> |
| <ul id="hotspots-list"> |
| {% for hotspot in point.get('hotspots', []) %} |
| <li class="hotspot-list-item" id="hotspot-item-{{ hotspot.id }}"> |
| <span> |
| <strong>Тип:</strong> {{ hotspot.type }} | |
| <strong>Цель:</strong> {{ hotspot.target }} | |
| <strong>Текст:</strong> {{ hotspot.text }} | |
| <strong>Коорд:</strong> (P: {{ "%.2f"|format(hotspot.pitch) }}, Y: {{ "%.2f"|format(hotspot.yaw) }}) |
| </span> |
| <div> |
| <form action="/admin/points/{{ point.id }}/hotspots/delete/{{ hotspot.id }}" method="post" style="display: inline;"> |
| <button type="submit" class="delete-button" onclick="return confirm('Удалить этот хотспот?');">Удалить</button> |
| </form> |
| </div> |
| </li> |
| {% else %} |
| <p>Нет хотспотов для этого поинта.</p> |
| {% endfor %} |
| </ul> |
| </div> |
| </div> |
| |
| <script src="https://cdn.jsdelivr.net/npm/pannellum@2.5.6/build/pannellum.js"></script> |
| <script> |
| const panoramaUrl = "{{ panorama_url }}"; |
| const pointId = "{{ point.id }}"; |
| const existingHotspots = {{ point.get('hotspots', []) | tojson }}; |
| let editorViewer = null; |
| |
| function updateHotspotTarget() { |
| const type = document.getElementById('hotspot_type').value; |
| document.querySelectorAll('.target-field').forEach(el => el.classList.add('hidden')); |
| |
| document.getElementById('target_point_id').required = false; |
| document.getElementById('target_link_url').required = false; |
| document.getElementById('target_category_id').required = false; |
| document.getElementById('target_product_id').required = false; |
| |
| if (type === 'point') { |
| document.getElementById('target-point').classList.remove('hidden'); |
| document.getElementById('target_point_id').required = true; |
| } else if (type === 'link') { |
| document.getElementById('target-link').classList.remove('hidden'); |
| document.getElementById('target_link_url').required = true; |
| } else if (type === 'category') { |
| document.getElementById('target-category').classList.remove('hidden'); |
| document.getElementById('target_category_id').required = true; |
| } else if (type === 'product') { |
| document.getElementById('target-product').classList.remove('hidden'); |
| document.getElementById('target_product_id').required = true; |
| } |
| } |
| |
| function clearHotspotForm() { |
| document.getElementById('hotspot-form').reset(); |
| document.getElementById('hotspot_id').value = ''; |
| document.getElementById('hotspot_pitch').readOnly = true; |
| document.getElementById('hotspot_yaw').readOnly = true; |
| document.getElementById('hotspot_pitch').placeholder = 'Кликните на панораму'; |
| document.getElementById('hotspot_yaw').placeholder = 'Кликните на панораму'; |
| updateHotspotTarget(); |
| } |
| |
| try { |
| editorViewer = pannellum.viewer('panorama-preview-container', { |
| "type": "equirectangular", |
| "panorama": panoramaUrl, |
| "autoLoad": true, |
| "showControls": true, |
| "hotSpotDebug": false, |
| "hotSpots": existingHotspots.map(hs => ({ |
| "pitch": hs.pitch, |
| "yaw": hs.yaw, |
| "cssClass": "pannellum-info-hotspot", |
| "text": `[${hs.type}] ${hs.text}` |
| })) |
| }); |
| |
| editorViewer.on('mousedown', function(event) { |
| if (event.target.classList.contains('pnlm-hotspot')) return; |
| |
| const coords = editorViewer.mouseEventToCoords(event); |
| document.getElementById('hotspot_pitch').value = coords[0].toFixed(4); |
| document.getElementById('hotspot_yaw').value = coords[1].toFixed(4); |
| document.getElementById('hotspot_pitch').readOnly = true; |
| document.getElementById('hotspot_yaw').readOnly = true; |
| document.getElementById('hotspot_pitch').placeholder = ''; |
| document.getElementById('hotspot_yaw').placeholder = ''; |
| document.getElementById('hotspot_text').focus(); |
| }); |
| |
| editorViewer.on('error', function(err) { |
| console.error('Ошибка редактора Pannellum:', err); |
| document.getElementById('panorama-preview-container').innerHTML = '<p style="color: red; text-align: center;">Ошибка загрузки предпросмотра панорамы: ' + err + '</p>'; |
| }); |
| |
| } catch (e) { |
| console.error("Не удалось инициализировать редактор Pannellum:", e); |
| document.getElementById('panorama-preview-container').innerHTML = '<p style="color: red; text-align: center;">Не удалось инициализировать просмотрщик панорам.</p>'; |
| } |
| |
| updateHotspotTarget(); |
| </script> |
| </body> |
| </html> |
| """ |
| return render_template_string(edit_point_html, point=point, panorama_url=panorama_url, |
| all_points=all_points, all_categories=all_categories, |
| all_standalone_products=all_standalone_products) |
|
|
| @app.route('/admin/points/<point_id>/hotspots/save', methods=['POST']) |
| def admin_save_hotspot(point_id): |
| point = find_point(point_id) |
| if not point: |
| return redirect(url_for('admin_panel')) |
|
|
| try: |
| pitch_str = request.form.get('pitch') |
| yaw_str = request.form.get('yaw') |
| text = request.form.get('text') |
| type = request.form.get('type') |
| hotspot_id = request.form.get('hotspot_id') |
|
|
| if not pitch_str or not yaw_str: |
| logging.warning("Координаты Pitch/Yaw отсутствуют. Кликните на панораму.") |
| return redirect(url_for('admin_edit_point', point_id=point_id)) |
|
|
| pitch = float(pitch_str) |
| yaw = float(yaw_str) |
|
|
| target = None |
| if type == 'point': |
| target = request.form.get('target_point_id') |
| elif type == 'link': |
| target = request.form.get('target_link_url') |
| elif type == 'category': |
| target = request.form.get('target_category_id') |
| elif type == 'product': |
| target = request.form.get('target_product_id') |
|
|
| if not all([pitch is not None, yaw is not None, text, type, target]): |
| logging.warning("Отсутствуют данные для сохранения хотспота.") |
| return redirect(url_for('admin_edit_point', point_id=point_id)) |
|
|
| new_hotspot = { |
| "id": generate_id(), |
| "pitch": pitch, |
| "yaw": yaw, |
| "type": type, |
| "target": target, |
| "text": text |
| } |
|
|
| point.setdefault('hotspots', []).append(new_hotspot) |
| save_data() |
| logging.info(f"Добавлен хотспот к поинту {point_id}: Тип={type}, Цель={target}") |
|
|
| except ValueError: |
| logging.error("Неверный формат числа для pitch или yaw.") |
| except Exception as e: |
| logging.error(f"Ошибка сохранения хотспота для поинта {point_id}: {e}") |
|
|
| return redirect(url_for('admin_edit_point', point_id=point_id)) |
|
|
| @app.route('/admin/points/<point_id>/hotspots/delete/<hotspot_id>', methods=['POST']) |
| def admin_delete_hotspot(point_id, hotspot_id): |
| point = find_point(point_id) |
| if point and 'hotspots' in point: |
| initial_length = len(point['hotspots']) |
| point['hotspots'] = [hs for hs in point['hotspots'] if hs.get('id') != hotspot_id] |
| if len(point['hotspots']) < initial_length: |
| save_data() |
| logging.info(f"Удален хотспот {hotspot_id} из поинта {point_id}") |
| else: |
| logging.warning(f"Хотспот {hotspot_id} не найден в поинте {point_id} для удаления.") |
| else: |
| logging.warning(f"Поинт {point_id} не найден или не имеет хотспотов для удаления.") |
|
|
| return redirect(url_for('admin_edit_point', point_id=point_id)) |
|
|
| @app.route('/admin/categories/add', methods=['POST']) |
| def admin_add_category(): |
| category_name = request.form.get('category_name') |
| if category_name: |
| if any(c.get('name', '').lower() == category_name.lower() for c in app_data.get('categories', [])): |
| logging.warning(f"Попытка добавить дубликат имени категории: {category_name}") |
| else: |
| new_category = { |
| "id": generate_id(), |
| "name": category_name, |
| "products": [] |
| } |
| app_data.setdefault('categories', []).append(new_category) |
| save_data() |
| logging.info(f"Добавлена категория: {category_name}") |
| else: |
| logging.warning("Попытка добавить категорию с пустым именем.") |
|
|
| return redirect(url_for('admin_panel')) |
|
|
| @app.route('/admin/categories/delete/<category_id>', methods=['POST']) |
| def admin_delete_category(category_id): |
| category_index = next((index for (index, c) in enumerate(app_data.get('categories', [])) if c.get('id') == category_id), None) |
| if category_index is not None: |
| category = app_data['categories'][category_index] |
| category_name = category.get('name', category_id) |
| products_to_delete = category.get('products', []) |
|
|
| hf_delete_failed_overall = False |
| for product in products_to_delete: |
| if delete_product_files_from_hf(product): |
| hf_delete_failed_overall = True |
|
|
| deleted_category = app_data['categories'].pop(category_index) |
| save_data() |
| logging.info(f"Удалена категория: {category_name} и ее {len(products_to_delete)} товаров.") |
|
|
| for product in products_to_delete: |
| cleanup_local_product_files(product) |
|
|
| if hf_delete_failed_overall: |
| pass |
| else: |
| pass |
| else: |
| logging.warning(f"Попытка удалить несуществующую категорию с ID: {category_id}") |
|
|
| return redirect(url_for('admin_panel')) |
|
|
| @app.route('/admin/category/<category_id>/products', methods=['GET']) |
| def admin_manage_category_products(category_id): |
| category = find_category(category_id) |
| if not category: |
| return redirect(url_for('admin_panel')) |
|
|
| products_with_urls = [] |
| for product in category.get('products', []): |
| photo_urls = [get_hf_image_url(REPO_ID, p) for p in product.get('photo_paths', []) if p] |
| products_with_urls.append({**product, 'photo_urls': photo_urls}) |
|
|
| manage_products_html = """ |
| <!DOCTYPE html> |
| <html lang="ru"> |
| <head> |
| <meta charset="UTF-8"> |
| <title>Управление Товарами: {{ category.name }}</title> |
| <style> |
| body { font-family: sans-serif; padding: 20px; background-color: #f4f4f4; } |
| .container { max-width: 1200px; margin: auto; background: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.1); } |
| h1, h2, h3 { color: #333; border-bottom: 1px solid #eee; padding-bottom: 10px; margin-bottom: 20px; } |
| .section { margin-bottom: 30px; padding: 20px; border: 1px solid #ddd; border-radius: 5px; background-color: #fafafa; } |
| label { display: block; margin-bottom: 5px; font-weight: bold; } |
| input[type=text], input[type=number], textarea, select { |
| width: calc(100% - 22px); padding: 10px; margin-bottom: 15px; border: 1px solid #ccc; border-radius: 4px; |
| } |
| input[type=file] { margin-bottom: 15px; } |
| button, .button { |
| background-color: #5cb85c; color: white; padding: 10px 15px; border: none; border-radius: 4px; |
| cursor: pointer; text-decoration: none; display: inline-block; margin-right: 5px; |
| } |
| button:hover, .button:hover { background-color: #4cae4c; } |
| .delete-button { background-color: #d9534f; } |
| .delete-button:hover { background-color: #c9302c; } |
| .edit-button { background-color: #f0ad4e; } |
| .edit-button:hover { background-color: #ec971f; } |
| ul { list-style: none; padding: 0; } |
| li { background: #fff; border: 1px solid #eee; margin-bottom: 10px; padding: 15px; border-radius: 4px; display: flex; flex-direction: column; } |
| .product-info { display: flex; justify-content: space-between; align-items: flex-start; width: 100%; gap: 15px; } |
| .product-details { flex-grow: 1; margin-right: 10px; } |
| .product-actions { flex-shrink: 0; white-space: nowrap; } |
| .product-photos { margin-top: 10px; } |
| .product-photos img { max-width: 80px; max-height: 80px; margin-right: 5px; border: 1px solid #eee; vertical-align: middle; border-radius: 4px;} |
| .edit-form { margin-top: 15px; padding: 15px; background: #f9f9f9; border: 1px dashed #ccc; border-radius: 5px; display: none; } |
| .edit-form.visible { display: block; } |
| </style> |
| </head> |
| <body> |
| <div class="container"> |
| <h1>Управление Товарами в Категории: {{ category.name }}</h1> |
| <a href="{{ url_for('admin_panel') }}" class="button">« Назад к админ-панели</a> |
| |
| <div class="section"> |
| <h2>Добавить Новый Товар</h2> |
| <form action="{{ url_for('admin_add_product_to_category', category_id=category.id) }}" method="post" enctype="multipart/form-data"> |
| <label for="product_name">Название:</label> |
| <input type="text" id="product_name" name="name" required> |
| <label for="product_price">Цена (SOM):</label> |
| <input type="number" id="product_price" name="price" step="0.01" min="0" required> |
| <label for="product_description">Описание:</label> |
| <textarea id="product_description" name="description" rows="3" required></textarea> |
| <label for="product_photos">Фотографии (до 10, JPG/PNG/GIF):</label> |
| <input type="file" id="product_photos" name="photos" accept="image/*" multiple> |
| <button type="submit">Добавить Товар в Категорию</button> |
| </form> |
| </div> |
| |
| <div class="section"> |
| <h2>Существующие Товары в "{{ category.name }}"</h2> |
| <ul id="products-list"> |
| {% for product in products %} |
| <li id="product-item-{{ product.id }}"> |
| <div class="product-info"> |
| <div class="product-details"> |
| <strong>{{ product.name }}</strong><br> |
| Цена: {{ product.price }} SOM<br> |
| Описание: {{ product.description[:100] }}{% if product.description|length > 100 %}...{% endif %}<br> |
| <small>ID: {{ product.id }}</small> |
| </div> |
| <div class="product-actions"> |
| <button class="edit-button" onclick="toggleEditForm('{{ product.id }}')">Редактировать</button> |
| <form action="{{ url_for('admin_delete_product_from_category', category_id=category.id, product_id=product.id) }}" method="post" style="display: inline;"> |
| <button type="submit" class="delete-button" onclick="return confirm('Вы уверены, что хотите удалить этот товар из категории?');">Удалить</button> |
| </form> |
| </div> |
| </div> |
| <div class="product-photos"> |
| {% if product.photo_urls %} |
| {% for url in product.photo_urls %} |
| <img src="{{ url }}" alt="{{ product.name }} фото"> |
| {% endfor %} |
| {% else %} |
| <small>Нет фото</small> |
| {% endif %} |
| </div> |
| |
| <div id="edit-form-{{ product.id }}" class="edit-form"> |
| <h3>Редактировать: {{ product.name }}</h3> |
| <form action="{{ url_for('admin_edit_product_in_category', category_id=category.id, product_id=product.id) }}" method="post" enctype="multipart/form-data"> |
| <label>Название:</label> |
| <input type="text" name="name" value="{{ product.name }}" required> |
| <label>Цена (SOM):</label> |
| <input type="number" name="price" step="0.01" min="0" value="{{ product.price }}" required> |
| <label>Описание:</label> |
| <textarea name="description" rows="3" required>{{ product.description }}</textarea> |
| <label>Заменить Фотографии (опционально, до 10):</label> |
| <input type="file" name="photos" accept="image/*" multiple> |
| <small>Если выбраны новые фото, они ЗАМЕНЯТ все старые.</small><br><br> |
| <button type="submit">Сохранить Изменения</button> |
| <button type="button" onclick="toggleEditForm('{{ product.id }}')">Отмена</button> |
| </form> |
| </div> |
| </li> |
| {% else %} |
| <p>В этой категории пока нет товаров.</p> |
| {% endfor %} |
| </ul> |
| </div> |
| </div> |
| |
| <script> |
| function toggleEditForm(productId) { |
| const form = document.getElementById(`edit-form-${productId}`); |
| form.classList.toggle('visible'); |
| } |
| </script> |
| </body> |
| </html> |
| """ |
| return render_template_string(manage_products_html, category=category, products=products_with_urls) |
|
|
| @app.route('/admin/category/<category_id>/products/add', methods=['POST']) |
| def admin_add_product_to_category(category_id): |
| category = find_category(category_id) |
| if not category: |
| return redirect(url_for('admin_panel')) |
|
|
| name = request.form.get('name') |
| price = request.form.get('price') |
| description = request.form.get('description') |
| photos = request.files.getlist('photos') |
|
|
| if not name or price is None or description is None: |
| return redirect(url_for('admin_manage_category_products', category_id=category_id)) |
|
|
| try: |
| price_float = float(price) |
| if price_float < 0: raise ValueError("Цена не может быть отрицательной") |
| except ValueError: |
| return redirect(url_for('admin_manage_category_products', category_id=category_id)) |
|
|
| photo_hf_paths = [] |
| upload_failed = False |
| uploaded_local_paths = [] |
|
|
| for photo in photos[:10]: |
| if photo and photo.filename: |
| filename = secure_filename(photo.filename) |
| unique_filename = f"{generate_id()}_{filename}" |
| local_path = os.path.join(PRODUCT_PHOTO_UPLOADS, unique_filename) |
| hf_path = f"product_photos/{unique_filename}" |
| uploaded_local_paths.append(local_path) |
|
|
| try: |
| photo.save(local_path) |
| commit_msg = f"Добавить фото для товара: {name} в категорию {category.get('name')}" |
| if upload_file_to_hf(local_path, hf_path, REPO_ID, HF_TOKEN_WRITE, commit_msg): |
| photo_hf_paths.append(hf_path) |
| else: |
| upload_failed = True |
| logging.error(f"Не удалось загрузить фото товара {filename} в HF.") |
| except Exception as e: |
| upload_failed = True |
| logging.error(f"Ошибка обработки фото товара {filename}: {e}") |
|
|
| new_product = { |
| "id": generate_id(), |
| "name": name, |
| "price": price_float, |
| "description": description, |
| "photo_paths": photo_hf_paths |
| } |
| category.setdefault('products', []).append(new_product) |
| save_data() |
| logging.info(f"Добавлен товар '{name}' в категорию '{category.get('name')}'. Фото: {len(photo_hf_paths)}.") |
|
|
| if upload_failed: |
| pass |
| else: |
| pass |
|
|
| for local_path in uploaded_local_paths: |
| if os.path.exists(local_path): |
| try: |
| os.remove(local_path) |
| except OSError as e: |
| logging.warning(f"Не удалось удалить временный локальный файл фото товара {local_path}: {e}") |
|
|
| return redirect(url_for('admin_manage_category_products', category_id=category_id)) |
|
|
| @app.route('/admin/category/<category_id>/products/edit/<product_id>', methods=['POST']) |
| def admin_edit_product_in_category(category_id, product_id): |
| category = find_category(category_id) |
| product = find_product_in_category(category_id, product_id) |
|
|
| if not category or not product: |
| return redirect(url_for('admin_panel')) |
|
|
| name = request.form.get('name') |
| price = request.form.get('price') |
| description = request.form.get('description') |
| new_photos = request.files.getlist('photos') |
|
|
| if not name or price is None or description is None: |
| return redirect(url_for('admin_manage_category_products', category_id=category_id)) |
|
|
| try: |
| price_float = float(price) |
| if price_float < 0: raise ValueError("Цена не может быть отрицательной") |
| except ValueError: |
| return redirect(url_for('admin_manage_category_products', category_id=category_id)) |
|
|
| product['name'] = name |
| product['price'] = price_float |
| product['description'] = description |
|
|
| if new_photos and any(p.filename for p in new_photos): |
| logging.info(f"Замена фото для товара {product_id} в категории {category_id}...") |
| old_photo_paths = product.get('photo_paths', []) |
| new_photo_hf_paths = [] |
| upload_failed = False |
| uploaded_local_paths = [] |
|
|
| for photo in new_photos[:10]: |
| if photo and photo.filename: |
| filename = secure_filename(photo.filename) |
| unique_filename = f"{generate_id()}_{filename}" |
| local_path = os.path.join(PRODUCT_PHOTO_UPLOADS, unique_filename) |
| hf_path = f"product_photos/{unique_filename}" |
| uploaded_local_paths.append(local_path) |
|
|
| try: |
| photo.save(local_path) |
| commit_msg = f"Обновить фото для товара: {name}" |
| if upload_file_to_hf(local_path, hf_path, REPO_ID, HF_TOKEN_WRITE, commit_msg): |
| new_photo_hf_paths.append(hf_path) |
| else: |
| upload_failed = True |
| logging.error(f"Не удалось загрузить новое фото {filename} в HF.") |
| except Exception as e: |
| upload_failed = True |
| logging.error(f"Ошибка обработки нового фото {filename}: {e}") |
|
|
| if len(new_photo_hf_paths) > 0: |
| logging.info(f"Удаление {len(old_photo_paths)} старых фото для товара {product_id} из HF...") |
| hf_delete_failed = False |
| for old_hf_path in old_photo_paths: |
| commit_msg = f"Удалить старое фото {os.path.basename(old_hf_path)} для товара {name} при обновлении" |
| if not delete_file_from_hf(old_hf_path, REPO_ID, HF_TOKEN_WRITE, commit_msg): |
| logging.warning(f"Не удалось удалить старое фото {old_hf_path} из HF при обновлении.") |
| hf_delete_failed = True |
| product['photo_paths'] = new_photo_hf_paths |
| if hf_delete_failed: |
| pass |
|
|
| elif upload_failed: |
| logging.error(f"Замена фото не удалась для товара {product_id}. Старые фото сохранены.") |
|
|
| for local_path in uploaded_local_paths: |
| if os.path.exists(local_path): |
| try: |
| os.remove(local_path) |
| except OSError as e: |
| logging.warning(f"Не удалось удалить временный локальный файл нового фото {local_path}: {e}") |
|
|
| save_data() |
| logging.info(f"Обновлен товар '{name}' (ID: {product_id}) в категории '{category.get('name')}'.") |
|
|
| return redirect(url_for('admin_manage_category_products', category_id=category_id)) |
|
|
| @app.route('/admin/category/<category_id>/products/delete/<product_id>', methods=['POST']) |
| def admin_delete_product_from_category(category_id, product_id): |
| category = find_category(category_id) |
| if not category or 'products' not in category: |
| return redirect(url_for('admin_panel')) |
|
|
| product_index = next((index for (index, p) in enumerate(category['products']) if p.get('id') == product_id), None) |
|
|
| if product_index is not None: |
| product = category['products'][product_index] |
| product_name = product.get('name', product_id) |
|
|
| hf_delete_failed = delete_product_files_from_hf(product) |
|
|
| deleted_product = category['products'].pop(product_index) |
| save_data() |
| logging.info(f"Удален товар: {product_name} из категории {category.get('name')}") |
|
|
| cleanup_local_product_files(product) |
|
|
| if hf_delete_failed: |
| pass |
| else: |
| pass |
| else: |
| logging.warning(f"Товар {product_id} не найден в категории {category_id} для удаления.") |
|
|
| return redirect(url_for('admin_manage_category_products', category_id=category_id)) |
|
|
| @app.route('/admin/products/standalone', methods=['GET']) |
| def admin_manage_standalone_products(): |
| products_with_urls = [] |
| for product in app_data.get('standalone_products', []): |
| photo_urls = [get_hf_image_url(REPO_ID, p) for p in product.get('photo_paths', []) if p] |
| products_with_urls.append({**product, 'photo_urls': photo_urls}) |
|
|
| manage_standalone_html = """ |
| <!DOCTYPE html> |
| <html lang="ru"> |
| <head> |
| <meta charset="UTF-8"> |
| <title>Управление Отдельными Товарами</title> |
| <style> |
| body { font-family: sans-serif; padding: 20px; background-color: #f4f4f4; } |
| .container { max-width: 1200px; margin: auto; background: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.1); } |
| h1, h2, h3 { color: #333; border-bottom: 1px solid #eee; padding-bottom: 10px; margin-bottom: 20px; } |
| .section { margin-bottom: 30px; padding: 20px; border: 1px solid #ddd; border-radius: 5px; background-color: #fafafa; } |
| label { display: block; margin-bottom: 5px; font-weight: bold; } |
| input[type=text], input[type=number], textarea, select { |
| width: calc(100% - 22px); padding: 10px; margin-bottom: 15px; border: 1px solid #ccc; border-radius: 4px; |
| } |
| input[type=file] { margin-bottom: 15px; } |
| button, .button { |
| background-color: #5cb85c; color: white; padding: 10px 15px; border: none; border-radius: 4px; |
| cursor: pointer; text-decoration: none; display: inline-block; margin-right: 5px; |
| } |
| button:hover, .button:hover { background-color: #4cae4c; } |
| .delete-button { background-color: #d9534f; } |
| .delete-button:hover { background-color: #c9302c; } |
| .edit-button { background-color: #f0ad4e; } |
| .edit-button:hover { background-color: #ec971f; } |
| ul { list-style: none; padding: 0; } |
| li { background: #fff; border: 1px solid #eee; margin-bottom: 10px; padding: 15px; border-radius: 4px; display: flex; flex-direction: column; } |
| .product-info { display: flex; justify-content: space-between; align-items: flex-start; width: 100%; gap: 15px;} |
| .product-details { flex-grow: 1; margin-right: 10px; } |
| .product-actions { flex-shrink: 0; white-space: nowrap; } |
| .product-photos { margin-top: 10px; } |
| .product-photos img { max-width: 80px; max-height: 80px; margin-right: 5px; border: 1px solid #eee; vertical-align: middle; border-radius: 4px;} |
| .edit-form { margin-top: 15px; padding: 15px; background: #f9f9f9; border: 1px dashed #ccc; border-radius: 5px; display: none; } |
| .edit-form.visible { display: block; } |
| </style> |
| </head> |
| <body> |
| <div class="container"> |
| <h1>Управление Отдельными Товарами</h1> |
| <p>Эти товары можно выбирать напрямую при создании хотспота типа "Товар". Они не принадлежат к какой-либо категории.</p> |
| <a href="{{ url_for('admin_panel') }}" class="button">« Назад к главной админ-панели</a> |
| |
| <div class="section"> |
| <h2>Добавить Новый Отдельный Товар</h2> |
| <form action="{{ url_for('admin_add_standalone_product') }}" method="post" enctype="multipart/form-data"> |
| <label for="product_name">Название:</label> |
| <input type="text" id="product_name" name="name" required> |
| <label for="product_price">Цена (SOM):</label> |
| <input type="number" id="product_price" name="price" step="0.01" min="0" required> |
| <label for="product_description">Описание:</label> |
| <textarea id="product_description" name="description" rows="3" required></textarea> |
| <label for="product_photos">Фотографии (до 10, JPG/PNG/GIF):</label> |
| <input type="file" id="product_photos" name="photos" accept="image/*" multiple> |
| <button type="submit">Добавить Отдельный Товар</button> |
| </form> |
| </div> |
| |
| <div class="section"> |
| <h2>Существующие Отдельные Товары</h2> |
| <ul id="products-list"> |
| {% for product in products %} |
| <li id="product-item-{{ product.id }}"> |
| <div class="product-info"> |
| <div class="product-details"> |
| <strong>{{ product.name }}</strong><br> |
| Цена: {{ product.price }} SOM<br> |
| Описание: {{ product.description[:100] }}{% if product.description|length > 100 %}...{% endif %}<br> |
| <small>ID: {{ product.id }}</small> |
| </div> |
| <div class="product-actions"> |
| <button class="edit-button" onclick="toggleEditForm('{{ product.id }}')">Редактировать</button> |
| <form action="{{ url_for('admin_delete_standalone_product', product_id=product.id) }}" method="post" style="display: inline;"> |
| <button type="submit" class="delete-button" onclick="return confirm('Вы уверены, что хотите удалить этот отдельный товар? Это действие необратимо.');">Удалить</button> |
| </form> |
| </div> |
| </div> |
| <div class="product-photos"> |
| {% if product.photo_urls %} |
| {% for url in product.photo_urls %} |
| <img src="{{ url }}" alt="{{ product.name }} фото"> |
| {% endfor %} |
| {% else %} |
| <small>Нет фото</small> |
| {% endif %} |
| </div> |
| |
| <div id="edit-form-{{ product.id }}" class="edit-form"> |
| <h3>Редактировать: {{ product.name }}</h3> |
| <form action="{{ url_for('admin_edit_standalone_product', product_id=product.id) }}" method="post" enctype="multipart/form-data"> |
| <label>Название:</label> |
| <input type="text" name="name" value="{{ product.name }}" required> |
| <label>Цена (SOM):</label> |
| <input type="number" name="price" step="0.01" min="0" value="{{ product.price }}" required> |
| <label>Описание:</label> |
| <textarea name="description" rows="3" required>{{ product.description }}</textarea> |
| <label>Заменить Фотографии (опционально, до 10):</label> |
| <input type="file" name="photos" accept="image/*" multiple> |
| <small>Если выбраны новые фото, они ЗАМЕНЯТ все старые.</small><br><br> |
| <button type="submit">Сохранить Изменения</button> |
| <button type="button" onclick="toggleEditForm('{{ product.id }}')">Отмена</button> |
| </form> |
| </div> |
| </li> |
| {% else %} |
| <p>Пока нет отдельных товаров.</p> |
| {% endfor %} |
| </ul> |
| </div> |
| </div> |
| |
| <script> |
| function toggleEditForm(productId) { |
| const form = document.getElementById(`edit-form-${productId}`); |
| form.classList.toggle('visible'); |
| } |
| </script> |
| </body> |
| </html> |
| """ |
| return render_template_string(manage_standalone_html, products=products_with_urls) |
|
|
| @app.route('/admin/products/standalone/add', methods=['POST']) |
| def admin_add_standalone_product(): |
| name = request.form.get('name') |
| price = request.form.get('price') |
| description = request.form.get('description') |
| photos = request.files.getlist('photos') |
|
|
| if not name or price is None or description is None: |
| return redirect(url_for('admin_manage_standalone_products')) |
|
|
| try: |
| price_float = float(price) |
| if price_float < 0: raise ValueError("Цена не может быть отрицательной") |
| except ValueError: |
| return redirect(url_for('admin_manage_standalone_products')) |
|
|
| photo_hf_paths = [] |
| upload_failed = False |
| uploaded_local_paths = [] |
|
|
| for photo in photos[:10]: |
| if photo and photo.filename: |
| filename = secure_filename(photo.filename) |
| unique_filename = f"{generate_id()}_{filename}" |
| local_path = os.path.join(PRODUCT_PHOTO_UPLOADS, unique_filename) |
| hf_path = f"product_photos/standalone/{unique_filename}" |
| uploaded_local_paths.append(local_path) |
|
|
| try: |
| photo.save(local_path) |
| commit_msg = f"Добавить фото для отдельного товара: {name}" |
| if upload_file_to_hf(local_path, hf_path, REPO_ID, HF_TOKEN_WRITE, commit_msg): |
| photo_hf_paths.append(hf_path) |
| else: |
| upload_failed = True |
| logging.error(f"Не удалось загрузить фото отдельного товара {filename} в HF.") |
| except Exception as e: |
| upload_failed = True |
| logging.error(f"Ошибка обработки фото отдельного товара {filename}: {e}") |
|
|
| new_product = { |
| "id": generate_id(), |
| "name": name, |
| "price": price_float, |
| "description": description, |
| "photo_paths": photo_hf_paths |
| } |
| app_data.setdefault('standalone_products', []).append(new_product) |
| save_data() |
| logging.info(f"Добавлен отдельный товар '{name}'. Фото: {len(photo_hf_paths)}.") |
|
|
| if upload_failed: |
| pass |
| else: |
| pass |
|
|
| for local_path in uploaded_local_paths: |
| if os.path.exists(local_path): |
| try: |
| os.remove(local_path) |
| except OSError as e: |
| logging.warning(f"Не удалось удалить временный локальный файл фото товара {local_path}: {e}") |
|
|
| return redirect(url_for('admin_manage_standalone_products')) |
|
|
| @app.route('/admin/products/standalone/edit/<product_id>', methods=['POST']) |
| def admin_edit_standalone_product(product_id): |
| product = find_standalone_product(product_id) |
|
|
| if not product: |
| return redirect(url_for('admin_manage_standalone_products')) |
|
|
| name = request.form.get('name') |
| price = request.form.get('price') |
| description = request.form.get('description') |
| new_photos = request.files.getlist('photos') |
|
|
| if not name or price is None or description is None: |
| return redirect(url_for('admin_manage_standalone_products')) |
|
|
| try: |
| price_float = float(price) |
| if price_float < 0: raise ValueError("Цена не может быть отрицательной") |
| except ValueError: |
| return redirect(url_for('admin_manage_standalone_products')) |
|
|
| product['name'] = name |
| product['price'] = price_float |
| product['description'] = description |
|
|
| if new_photos and any(p.filename for p in new_photos): |
| logging.info(f"Замена фото для отдельного товара {product_id}...") |
| old_photo_paths = product.get('photo_paths', []) |
| new_photo_hf_paths = [] |
| upload_failed = False |
| uploaded_local_paths = [] |
|
|
| for photo in new_photos[:10]: |
| if photo and photo.filename: |
| filename = secure_filename(photo.filename) |
| unique_filename = f"{generate_id()}_{filename}" |
| local_path = os.path.join(PRODUCT_PHOTO_UPLOADS, unique_filename) |
| hf_path = f"product_photos/standalone/{unique_filename}" |
| uploaded_local_paths.append(local_path) |
|
|
| try: |
| photo.save(local_path) |
| commit_msg = f"Обновить фото для отдельного товара: {name}" |
| if upload_file_to_hf(local_path, hf_path, REPO_ID, HF_TOKEN_WRITE, commit_msg): |
| new_photo_hf_paths.append(hf_path) |
| else: |
| upload_failed = True |
| logging.error(f"Не удалось загрузить новое фото {filename} в HF.") |
| except Exception as e: |
| upload_failed = True |
| logging.error(f"Ошибка обработки нового фото {filename}: {e}") |
|
|
| if len(new_photo_hf_paths) > 0: |
| logging.info(f"Удаление {len(old_photo_paths)} старых фото для товара {product_id} из HF...") |
| hf_delete_failed = False |
| for old_hf_path in old_photo_paths: |
| commit_msg = f"Удалить старое фото {os.path.basename(old_hf_path)} для товара {name} при обновлении" |
| if not delete_file_from_hf(old_hf_path, REPO_ID, HF_TOKEN_WRITE, commit_msg): |
| logging.warning(f"Не удалось удалить старое фото {old_hf_path} из HF при обновлении.") |
| hf_delete_failed = True |
| product['photo_paths'] = new_photo_hf_paths |
| if hf_delete_failed: |
| pass |
| elif upload_failed: |
| logging.error(f"Замена фото не удалась для товара {product_id}. Старые фото сохранены.") |
|
|
| for local_path in uploaded_local_paths: |
| if os.path.exists(local_path): |
| try: |
| os.remove(local_path) |
| except OSError as e: |
| logging.warning(f"Не удалось удалить временный локальный файл нового фото {local_path}: {e}") |
|
|
| save_data() |
| logging.info(f"Обновлен отдельный товар '{name}' (ID: {product_id}).") |
|
|
| return redirect(url_for('admin_manage_standalone_products', product_id=product_id)) |
|
|
| @app.route('/admin/products/standalone/delete/<product_id>', methods=['POST']) |
| def admin_delete_standalone_product(product_id): |
| product_index = next((index for (index, p) in enumerate(app_data.get('standalone_products', [])) if p.get('id') == product_id), None) |
|
|
| if product_index is not None: |
| product = app_data['standalone_products'][product_index] |
| product_name = product.get('name', product_id) |
|
|
| hf_delete_failed = delete_product_files_from_hf(product) |
|
|
| deleted_product = app_data['standalone_products'].pop(product_index) |
| save_data() |
| logging.info(f"Удален отдельный товар: {product_name}") |
|
|
| cleanup_local_product_files(product) |
|
|
| if hf_delete_failed: |
| pass |
| else: |
| pass |
| else: |
| logging.warning(f"Отдельный товар {product_id} не найден для удаления.") |
|
|
| return redirect(url_for('admin_manage_standalone_products')) |
|
|
| @app.route('/backup', methods=['POST']) |
| def manual_backup(): |
| logging.info("Запущено ручное резервное копирование базы данных.") |
| save_data() |
| return redirect(url_for('admin_panel')) |
|
|
| if __name__ == '__main__': |
| logging.info("Попытка начальной загрузки данных...") |
| download_db_from_hf() |
|
|
| backup_thread = threading.Thread(target=periodic_backup, daemon=True) |
| backup_thread.start() |
| logging.info("Поток периодического резервного копирования запущен.") |
|
|
| logging.info("Запуск приложения Flask...") |
| app.run(debug=False, host='0.0.0.0', port=7860) |
|
|
|
|
|
|
|
|