From 28a5d5138969cb17ff22e5509846500ef8ccbdad Mon Sep 17 00:00:00 2001 From: xds Date: Sun, 22 Mar 2026 12:40:33 +0300 Subject: [PATCH] init --- .env.example | 9 + .gitignore | 9 + .idea/.gitignore | 10 + CLAUDE.md | 787 ++++++ backend/Dockerfile | 16 + backend/alembic.ini | 36 + backend/alembic/env.py | 49 + backend/alembic/script.py.mako | 23 + backend/app/__init__.py | 0 backend/app/config.py | 21 + backend/app/database.py | 24 + backend/app/main.py | 90 + backend/app/models/__init__.py | 5 + backend/app/models/calculation.py | 34 + backend/app/models/material.py | 28 + backend/app/models/order.py | 25 + backend/app/routers/__init__.py | 0 backend/app/routers/ai_advisor.py | 76 + backend/app/routers/calculate.py | 200 ++ backend/app/routers/materials.py | 52 + backend/app/routers/orders.py | 106 + backend/app/schemas/__init__.py | 0 backend/app/schemas/calculate.py | 65 + backend/app/schemas/material.py | 25 + backend/app/schemas/order.py | 18 + backend/app/seed/__init__.py | 0 backend/app/seed/materials.py | 114 + backend/app/services/__init__.py | 0 backend/app/services/ai_advisor.py | 104 + backend/app/services/file_parser.py | 62 + backend/app/services/price_engine.py | 140 + backend/app/services/storage.py | 67 + backend/app/services/telegram_notify.py | 48 + backend/requirements.txt | 13 + docker-compose.yml | 20 + frontend/.env.development | 1 + frontend/.env.production | 1 + frontend/Dockerfile | 12 + frontend/index.html | 15 + frontend/package-lock.json | 2727 ++++++++++++++++++++ frontend/package.json | 24 + frontend/postcss.config.js | 6 + frontend/src/App.vue | 35 + frontend/src/api/client.js | 8 + frontend/src/assets/styles/main.css | 27 + frontend/src/components/AiAdvisor.vue | 132 + frontend/src/components/FileUploader.vue | 125 + frontend/src/components/MaterialPicker.vue | 82 + frontend/src/components/OrderForm.vue | 77 + frontend/src/components/PriceResult.vue | 80 + frontend/src/components/PrintSettings.vue | 100 + frontend/src/main.js | 10 + frontend/src/router/index.js | 15 + frontend/src/stores/calculator.js | 63 + frontend/src/stores/materials.js | 31 + frontend/src/views/CalculatorView.vue | 58 + frontend/src/views/MaterialsView.vue | 62 + frontend/src/views/OrderView.vue | 45 + frontend/tailwind.config.js | 26 + frontend/vite.config.js | 10 + nginx/nginx.conf | 37 + 61 files changed, 6085 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 CLAUDE.md create mode 100644 backend/Dockerfile create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/app/__init__.py create mode 100644 backend/app/config.py create mode 100644 backend/app/database.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/calculation.py create mode 100644 backend/app/models/material.py create mode 100644 backend/app/models/order.py create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/ai_advisor.py create mode 100644 backend/app/routers/calculate.py create mode 100644 backend/app/routers/materials.py create mode 100644 backend/app/routers/orders.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/calculate.py create mode 100644 backend/app/schemas/material.py create mode 100644 backend/app/schemas/order.py create mode 100644 backend/app/seed/__init__.py create mode 100644 backend/app/seed/materials.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/ai_advisor.py create mode 100644 backend/app/services/file_parser.py create mode 100644 backend/app/services/price_engine.py create mode 100644 backend/app/services/storage.py create mode 100644 backend/app/services/telegram_notify.py create mode 100644 backend/requirements.txt create mode 100644 docker-compose.yml create mode 100644 frontend/.env.development create mode 100644 frontend/.env.production create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api/client.js create mode 100644 frontend/src/assets/styles/main.css create mode 100644 frontend/src/components/AiAdvisor.vue create mode 100644 frontend/src/components/FileUploader.vue create mode 100644 frontend/src/components/MaterialPicker.vue create mode 100644 frontend/src/components/OrderForm.vue create mode 100644 frontend/src/components/PriceResult.vue create mode 100644 frontend/src/components/PrintSettings.vue create mode 100644 frontend/src/main.js create mode 100644 frontend/src/router/index.js create mode 100644 frontend/src/stores/calculator.js create mode 100644 frontend/src/stores/materials.js create mode 100644 frontend/src/views/CalculatorView.vue create mode 100644 frontend/src/views/MaterialsView.vue create mode 100644 frontend/src/views/OrderView.vue create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/vite.config.js create mode 100644 nginx/nginx.conf diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e749d1b --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +DB_PASSWORD=print3d_secret +GOOGLE_API_KEY= +TELEGRAM_BOT_TOKEN=8730332716:AAFOZeGhWL99-3_s11cWo2GrpeUAMb6Qkw0 +TELEGRAM_CHAT_ID=567047 +MINIO_ENDPOINT=localhost:9000 +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin +MINIO_BUCKET=filam3d +MINIO_SECURE=false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b86d9c2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.env +__pycache__/ +*.pyc +node_modules/ +dist/ +.vite/ +uploads/ +*.egg-info/ +.DS_Store diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ad3265d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,787 @@ +# 3D Print Calculator — Техническое задание на MVP + +## Обзор проекта + +Сервис 3D-печати на заказ с автоматическим расчётом стоимости. Клиент загружает 3D-модель (STL, 3MF, OBJ), выбирает материал, получает мгновенный расчёт цены и оформляет заказ. AI-ассистент помогает выбрать оптимальный материал под задачу. + +**Бизнес-модель:** B2B — корпуса для электроники, функциональные запчасти, прототипы. + +**Стек:** Python (FastAPI) + Vue 3 (Vite) + PostgreSQL + Nginx + Docker Compose. + +**Хостинг:** VPS (Ubuntu 24), деплой через Docker Compose. + +--- + +## Архитектура + +``` +┌─────────────────────────────────────────────┐ +│ Nginx (reverse proxy) │ +│ :80 → frontend, /api → backend │ +└──────────┬──────────────┬───────────────────┘ + │ │ +┌──────────▼──────┐ ┌─────▼──────────────────┐ +│ Vue 3 (Vite) │ │ FastAPI (Python) │ +│ SPA, static │ │ REST API + WebSocket │ +│ Port: 5173 │ │ Port: 8000 │ +└─────────────────┘ └──────┬─────┬────────────┘ + │ │ + ┌──────▼┐ ┌──▼───────────┐ + │ PostgreSQL │ File Storage │ + │ :5432 │ (local /uploads) │ + └───────┘ └──────────────┘ +``` + +### Контейнеры Docker Compose + +1. **frontend** — Node 20 + Vite dev server (в проде — собранная статика через Nginx) +2. **backend** — Python 3.12 + FastAPI + Uvicorn +3. **db** — PostgreSQL 16 +4. **nginx** — reverse proxy, SSL termination + +--- + +## Backend (FastAPI) + +### Структура проекта + +``` +backend/ +├── app/ +│ ├── main.py # FastAPI app, CORS, middleware +│ ├── config.py # Settings (pydantic-settings) +│ ├── database.py # SQLAlchemy async engine + session +│ ├── models/ +│ │ ├── material.py # Material ORM model +│ │ ├── order.py # Order ORM model +│ │ └── file_upload.py # Uploaded file metadata +│ ├── schemas/ +│ │ ├── calculate.py # Request/Response для калькулятора +│ │ ├── material.py # Material schemas +│ │ └── order.py # Order schemas +│ ├── routers/ +│ │ ├── calculate.py # POST /api/calculate +│ │ ├── materials.py # GET /api/materials +│ │ ├── orders.py # POST /api/orders +│ │ └── ai_advisor.py # POST /api/advisor +│ ├── services/ +│ │ ├── file_parser.py # Парсинг STL/3MF/OBJ → геометрия +│ │ ├── price_engine.py # Расчёт стоимости +│ │ ├── ai_advisor.py # Интеграция с Claude API +│ │ └── telegram_notify.py # Уведомления в Telegram +│ └── seed/ +│ └── materials.py # Начальные данные по материалам +├── requirements.txt +├── Dockerfile +└── alembic/ # Миграции БД +``` + +### API Endpoints + +#### 1. POST /api/calculate + +Основной endpoint калькулятора. Принимает файл и параметры, возвращает расчёт. + +**Request:** `multipart/form-data` +- `file` (File, required) — 3D-модель (STL, 3MF, OBJ). Макс. размер: 50MB. +- `material_id` (int, required) — ID выбранного материала +- `infill_percent` (int, optional, default=30) — Процент заполнения (10-100) +- `layer_height_mm` (float, optional, default=0.2) — Высота слоя (0.08-0.4) +- `quantity` (int, optional, default=1) — Количество экземпляров (1-500) +- `post_processing` (str[], optional) — Постобработка: ["sanding", "painting", "threading"] + +**Response:** `application/json` +```json +{ + "success": true, + "file_info": { + "filename": "case_v3.stl", + "format": "stl", + "volume_cm3": 42.7, + "surface_area_cm2": 198.3, + "bounding_box_mm": {"x": 120.0, "y": 80.0, "z": 35.0}, + "is_watertight": true, + "triangle_count": 12840 + }, + "calculation": { + "material": { + "id": 3, + "name": "PA (Nylon)", + "density_g_cm3": 1.14, + "price_per_gram": 50.0 + }, + "weight_grams": 48.7, + "material_cost_rub": 2435.0, + "print_time_hours": 4.2, + "time_cost_rub": 840.0, + "post_processing_cost_rub": 500.0, + "subtotal_rub": 3775.0, + "quantity": 2, + "quantity_discount_percent": 5, + "total_rub": 7172.5, + "estimated_days": 3 + } +} +``` + +**Ошибки:** +- 400 — Неподдерживаемый формат файла +- 400 — Файл повреждён или не является 3D-моделью +- 400 — Модель не является водонепроницаемой (warning, не blocking) +- 413 — Файл слишком большой (>50MB) + +#### 2. GET /api/materials + +Список доступных материалов с характеристиками. + +**Response:** +```json +[ + { + "id": 1, + "name": "PLA", + "category": "basic", + "price_per_gram": 25.0, + "density_g_cm3": 1.24, + "properties": { + "max_temp_c": 60, + "strength": "medium", + "flexibility": "low", + "chemical_resistance": "low", + "food_safe": true + }, + "description": "Базовый пластик, подходит для прототипов и декоративных изделий", + "color_options": ["white", "black", "gray", "red", "blue", "green", "natural"] + } +] +``` + +#### 3. POST /api/advisor + +AI-ассистент для выбора материала. Принимает описание задачи, возвращает рекомендацию. + +**Request:** +```json +{ + "task_description": "Нужен корпус для уличного датчика температуры. Будет стоять на улице, диапазон температур от -30 до +50. Должен быть водонепроницаемым.", + "budget_preference": "optimal", + "file_info": { + "volume_cm3": 42.7, + "bounding_box_mm": {"x": 120, "y": 80, "z": 35} + } +} +``` + +**Response:** +```json +{ + "recommended_material_id": 4, + "recommended_material_name": "PETG", + "reasoning": "Для уличного корпуса датчика рекомендую PETG: термостойкость до +80°C, морозостойкость до -40°C, хорошая UV-стойкость и водонепроницаемость. ABS тоже подошёл бы, но PETG проще в печати и не требует закрытой камеры.", + "alternatives": [ + { + "material_id": 2, + "name": "ABS", + "why": "Выше ударопрочность, но требует постобработки для герметичности" + }, + { + "material_id": 5, + "name": "ASA", + "why": "Лучшая UV-стойкость, но дороже" + } + ] +} +``` + +**Реализация:** Отправляем запрос к Claude API (модель claude-sonnet-4-20250514) с системным промптом, содержащим каталог материалов и их свойства. Ключ API хранится в переменной окружения `ANTHROPIC_API_KEY`. + +#### 4. POST /api/orders + +Оформление заказа. + +**Request:** +```json +{ + "calculation_id": "uuid-of-saved-calculation", + "client_name": "Иван Петров", + "client_phone": "+79001234567", + "client_email": "ivan@example.com", + "client_company": "ООО Технопарк", + "delivery_method": "pickup", + "comment": "Нужно к пятнице, нанести резьбу M4 в двух отверстиях" +} +``` + +**Response:** +```json +{ + "order_id": "ORD-2026-0042", + "status": "pending", + "total_rub": 7172.5, + "estimated_ready_date": "2026-04-02" +} +``` + +**Side effect:** Отправляет уведомление в Telegram-бот владельца с деталями заказа. + +### Сервис парсинга файлов (file_parser.py) + +Используемые библиотеки: +- `trimesh` — основной парсер. Читает STL (binary + ASCII), OBJ, 3MF, PLY, GLTF. +- `numpy-stl` — запасной вариант для STL, если trimesh не справился. + +Что извлекаем из файла: +```python +import trimesh + +def parse_3d_file(file_path: str, file_extension: str) -> FileInfo: + """ + Парсит 3D-файл и возвращает геометрические характеристики. + + Поддерживаемые форматы: .stl, .3mf, .obj + STEP-файлы (.step, .stp) — в v2 (требует cadquery/OCP). + """ + mesh = trimesh.load(file_path, file_type=file_extension) + + # Если 3MF содержит несколько тел — объединяем + if isinstance(mesh, trimesh.Scene): + mesh = trimesh.util.concatenate(mesh.dump()) + + return FileInfo( + volume_cm3=mesh.volume / 1000, # mm³ → cm³ + surface_area_cm2=mesh.area / 100, # mm² → cm² + bounding_box_mm={ + "x": mesh.bounding_box.extents[0], + "y": mesh.bounding_box.extents[1], + "z": mesh.bounding_box.extents[2], + }, + is_watertight=mesh.is_watertight, + triangle_count=len(mesh.faces), + ) +``` + +### Сервис расчёта цены (price_engine.py) + +```python +def calculate_price( + file_info: FileInfo, + material: Material, + infill_percent: int = 30, + layer_height_mm: float = 0.2, + quantity: int = 1, + post_processing: list[str] = [], +) -> Calculation: + """ + Формула: + 1. effective_volume = volume_cm3 * (infill_percent / 100) * 0.7 + volume_cm3 * 0.3 + (70% объёма масштабируется по infill, 30% — стенки, всегда 100%) + 2. weight_g = effective_volume * material.density_g_cm3 + 3. material_cost = weight_g * material.price_per_gram + 4. print_time_h = estimate_print_time(file_info, layer_height_mm, material) + 5. time_cost = print_time_h * TIME_RATE_PER_HOUR # ~200 руб/час + 6. post_processing_cost = sum стоимостей выбранных операций + 7. subtotal = material_cost + time_cost + post_processing_cost + 8. total = subtotal * quantity * (1 - volume_discount(quantity)) + """ +``` + +**Оценка времени печати (упрощённая):** +```python +def estimate_print_time(file_info, layer_height_mm, material): + """ + Упрощённая оценка без полного слайсинга. + + layers = bounding_box_z / layer_height + volume_per_layer = volume_cm3 / layers * 1000 # mm³ + time_per_layer = volume_per_layer / material.flow_rate_mm3_s / 60 # минуты + travel_time_per_layer ≈ 0.3 мин (константа для Bambu Lab) + total = layers * (time_per_layer + travel_time) + setup_time + """ +``` + +**Скидки за количество:** +- 1 шт — 0% +- 2-5 шт — 5% +- 6-20 шт — 10% +- 21-100 шт — 15% +- 101-500 шт — 20% + +**Стоимость постобработки:** +- `sanding` (шлифовка) — 300 руб/шт +- `painting` (покраска) — 500 руб/шт +- `threading` (нарезка резьбы) — 200 руб/отверстие +- `acetone_smoothing` (ацетоновая обработка, только ABS) — 400 руб/шт + +### Справочник материалов (seed data) + +```python +MATERIALS = [ + { + "name": "PLA", + "category": "basic", + "density_g_cm3": 1.24, + "price_per_gram": 25.0, + "flow_rate_mm3_s": 15.0, + "max_temp_c": 60, + "min_temp_c": -20, + "strength": "medium", + "flexibility": "low", + "chemical_resistance": "low", + "uv_resistance": "low", + "food_safe": True, + "description": "Базовый пластик. Лёгкий в печати, хорошая детализация. Для прототипов и декора.", + }, + { + "name": "PETG", + "category": "basic", + "density_g_cm3": 1.27, + "price_per_gram": 28.0, + "flow_rate_mm3_s": 12.0, + "max_temp_c": 80, + "min_temp_c": -40, + "strength": "high", + "flexibility": "medium", + "chemical_resistance": "medium", + "uv_resistance": "medium", + "food_safe": True, + "description": "Универсальный инженерный пластик. Прочный, химстойкий, подходит для улицы.", + }, + { + "name": "ABS", + "category": "basic", + "density_g_cm3": 1.04, + "price_per_gram": 25.0, + "flow_rate_mm3_s": 12.0, + "max_temp_c": 100, + "min_temp_c": -30, + "strength": "high", + "flexibility": "low", + "chemical_resistance": "medium", + "uv_resistance": "low", + "food_safe": False, + "description": "Термостойкий, ударопрочный. Требует закрытой камеры. Обрабатывается ацетоном.", + }, + { + "name": "PA (Nylon)", + "category": "engineering", + "density_g_cm3": 1.14, + "price_per_gram": 50.0, + "flow_rate_mm3_s": 10.0, + "max_temp_c": 120, + "min_temp_c": -40, + "strength": "very_high", + "flexibility": "medium", + "chemical_resistance": "high", + "uv_resistance": "medium", + "food_safe": False, + "description": "Инженерный пластик. Высокая прочность, износостойкость. Для шестерён, креплений.", + }, + { + "name": "PC (Поликарбонат)", + "category": "engineering", + "density_g_cm3": 1.20, + "price_per_gram": 60.0, + "flow_rate_mm3_s": 8.0, + "max_temp_c": 140, + "min_temp_c": -40, + "strength": "very_high", + "flexibility": "low", + "chemical_resistance": "high", + "uv_resistance": "high", + "food_safe": False, + "description": "Максимальная термостойкость и прочность. Для корпусов, работающих при высоких температурах.", + }, + { + "name": "TPU", + "category": "engineering", + "density_g_cm3": 1.21, + "price_per_gram": 40.0, + "flow_rate_mm3_s": 6.0, + "max_temp_c": 80, + "min_temp_c": -30, + "strength": "medium", + "flexibility": "very_high", + "chemical_resistance": "high", + "uv_resistance": "medium", + "food_safe": False, + "description": "Эластичный пластик, аналог резины. Для прокладок, амортизаторов, гибких деталей.", + }, + { + "name": "PA-CF (Нейлон + углеволокно)", + "category": "composite", + "density_g_cm3": 1.18, + "price_per_gram": 75.0, + "flow_rate_mm3_s": 8.0, + "max_temp_c": 150, + "min_temp_c": -40, + "strength": "extreme", + "flexibility": "low", + "chemical_resistance": "very_high", + "uv_resistance": "high", + "food_safe": False, + "description": "Композит с углеволокном. Максимальная жёсткость и прочность. Замена алюминия.", + }, +] +``` + +### AI Advisor — системный промпт + +Файл `app/services/ai_advisor.py` использует Anthropic Python SDK: + +```python +SYSTEM_PROMPT = """ +Ты — эксперт по 3D-печати из инженерных пластиков по технологии FDM. +Твоя задача — рекомендовать оптимальный материал для печати на основе описания задачи клиента. + +Доступные материалы: +{materials_json} + +Правила: +1. Всегда рекомендуй один основной материал и 1-2 альтернативы. +2. Учитывай: температурный режим, механические нагрузки, химическое воздействие, UV, влажность. +3. Если задача не подходит для FDM-печати (слишком мелкие детали, высокая точность) — честно скажи об этом. +4. Отвечай кратко, по делу, на русском языке. +5. Если клиент не указал критичные параметры — задай уточняющие вопросы. + +Формат ответа — строго JSON: +{ + "recommended_material_id": , + "reasoning": "<обоснование на русском>", + "alternatives": [{"material_id": , "name": "", "why": "<причина>"}], + "questions": ["<вопрос, если нужна доп. информация>"] // пустой массив если вопросов нет +} +""" +``` + +### Telegram-уведомления (telegram_notify.py) + +При создании заказа отправляем сообщение в Telegram-бот владельца: + +```python +import httpx + +async def notify_new_order(order: Order): + """Отправляет уведомление о новом заказе в Telegram.""" + text = ( + f"🆕 Новый заказ #{order.order_id}\n" + f"Клиент: {order.client_name}\n" + f"Телефон: {order.client_phone}\n" + f"Материал: {order.material_name}\n" + f"Сумма: {order.total_rub} ₽\n" + f"Комментарий: {order.comment or '—'}" + ) + await httpx.AsyncClient().post( + f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage", + json={"chat_id": TELEGRAM_CHAT_ID, "text": text} + ) +``` + +Переменные окружения: `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`. + +--- + +## Frontend (Vue 3 + Vite) + +### Структура проекта + +``` +frontend/ +├── src/ +│ ├── App.vue +│ ├── main.js +│ ├── router/ +│ │ └── index.js # Vue Router +│ ├── stores/ +│ │ ├── calculator.js # Pinia store — состояние калькулятора +│ │ └── materials.js # Pinia store — материалы +│ ├── api/ +│ │ └── client.js # Axios instance, базовые запросы +│ ├── views/ +│ │ ├── CalculatorView.vue # Главная страница с калькулятором +│ │ ├── MaterialsView.vue # Каталог материалов +│ │ └── OrderView.vue # Форма заказа +│ ├── components/ +│ │ ├── FileUploader.vue # Drag-and-drop загрузка файла +│ │ ├── MaterialPicker.vue # Выбор материала (карточки) +│ │ ├── PrintSettings.vue # Настройки печати (infill, layer) +│ │ ├── PriceResult.vue # Отображение расчёта +│ │ ├── AiAdvisor.vue # Чат с AI-ассистентом +│ │ └── OrderForm.vue # Форма заказа +│ └── assets/ +│ └── styles/ +│ └── main.css # Tailwind CSS +├── index.html +├── vite.config.js +├── tailwind.config.js +├── package.json +└── Dockerfile +``` + +### Зависимости + +```json +{ + "dependencies": { + "vue": "^3.5", + "vue-router": "^4.4", + "pinia": "^2.2", + "axios": "^1.7" + }, + "devDependencies": { + "vite": "^6.0", + "@vitejs/plugin-vue": "^5.1", + "tailwindcss": "^3.4", + "autoprefixer": "^10.4", + "postcss": "^8.4" + } +} +``` + +### Страницы и маршруты + +| Путь | Компонент | Описание | +|------|-----------|----------| +| `/` | CalculatorView | Главная: загрузка файла → материал → настройки → цена | +| `/materials` | MaterialsView | Каталог материалов с фильтрами | +| `/order/:calcId` | OrderView | Форма оформления заказа | + +### Компонент FileUploader.vue + +Требования: +- Drag-and-drop зона + кнопка «Выбрать файл» +- Принимает: `.stl`, `.3mf`, `.obj` (валидация по расширению на фронте) +- Максимальный размер: 50 MB +- Отображает имя файла, размер и иконку формата после загрузки +- Показывает прогресс-бар при отправке на сервер +- При ошибке парсинга — человекочитаемое сообщение + +### Компонент MaterialPicker.vue + +Требования: +- Отображает материалы карточками (не dropdown) +- Карточка содержит: название, цену за грамм, ключевые свойства (иконки) +- Разделение на категории: «Базовые», «Инженерные», «Композитные» +- Выбранный материал подсвечивается +- Кнопка «Помочь выбрать» открывает AI-ассистент + +### Компонент PrintSettings.vue + +Требования: +- Слайдер: заполнение (10%-100%, шаг 10%, default 30%) +- Слайдер: высота слоя (0.08-0.4mm, шаг 0.04, default 0.2) +- Поле: количество (1-500, default 1) +- Чекбоксы: постобработка (шлифовка, покраска, резьба, ацетон) +- Подсказки при наведении: как параметр влияет на результат + +### Компонент PriceResult.vue + +Требования: +- Показывает разбивку: материал, время, постобработка, скидка, итого +- Крупно отображает итоговую цену +- Показывает примерный срок изготовления +- Кнопка «Оформить заказ» → переход на /order/:calcId +- Кнопка «Скачать расчёт (PDF)» — v2, пока не реализуем + +### Компонент AiAdvisor.vue + +Требования: +- Модальное окно или выдвижная панель справа +- Текстовое поле для описания задачи +- Кнопка «Получить рекомендацию» +- Отображает рекомендацию: основной материал + альтернативы с обоснованием +- Кнопка «Выбрать» рядом с каждой рекомендацией — применяет материал в калькулятор + +### Дизайн + +- Стиль: минималистичный, светлая тема, Tailwind CSS +- Акцентный цвет: #2563EB (синий) — кнопки, выделения +- Шрифт: Inter (Google Fonts) +- Адаптивность: mobile-first, работает на телефоне +- Тёмная тема: v2 (не в MVP) + +--- + +## База данных (PostgreSQL) + +### Таблицы + +**materials** +```sql +CREATE TABLE materials ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + category VARCHAR(50) NOT NULL, -- basic, engineering, composite + density_g_cm3 FLOAT NOT NULL, + price_per_gram FLOAT NOT NULL, + flow_rate_mm3_s FLOAT NOT NULL, + max_temp_c INT, + min_temp_c INT, + strength VARCHAR(20), -- low, medium, high, very_high, extreme + flexibility VARCHAR(20), + chemical_resistance VARCHAR(20), + uv_resistance VARCHAR(20), + food_safe BOOLEAN DEFAULT FALSE, + description TEXT, + color_options JSONB DEFAULT '[]', + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +**calculations** +```sql +CREATE TABLE calculations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + file_name VARCHAR(255) NOT NULL, + file_format VARCHAR(10) NOT NULL, + file_path VARCHAR(500), + volume_cm3 FLOAT NOT NULL, + surface_area_cm2 FLOAT, + bounding_box JSONB, + is_watertight BOOLEAN, + triangle_count INT, + material_id INT REFERENCES materials(id), + infill_percent INT DEFAULT 30, + layer_height_mm FLOAT DEFAULT 0.2, + quantity INT DEFAULT 1, + post_processing JSONB DEFAULT '[]', + weight_grams FLOAT, + material_cost_rub FLOAT, + time_cost_rub FLOAT, + post_processing_cost_rub FLOAT, + total_rub FLOAT, + estimated_days INT, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +**orders** +```sql +CREATE TABLE orders ( + id SERIAL PRIMARY KEY, + order_id VARCHAR(20) UNIQUE NOT NULL, -- ORD-2026-0001 + calculation_id UUID REFERENCES calculations(id), + client_name VARCHAR(200) NOT NULL, + client_phone VARCHAR(20) NOT NULL, + client_email VARCHAR(200), + client_company VARCHAR(200), + delivery_method VARCHAR(50) DEFAULT 'pickup', -- pickup, delivery + comment TEXT, + status VARCHAR(30) DEFAULT 'pending', -- pending, confirmed, printing, ready, delivered, cancelled + total_rub FLOAT NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +``` + +--- + +## Docker Compose + +```yaml +version: "3.8" + +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_DB: print3d + POSTGRES_USER: print3d + POSTGRES_PASSWORD: ${DB_PASSWORD} + volumes: + - pgdata:/var/lib/postgresql/data + ports: + - "5432:5432" + + backend: + build: ./backend + environment: + DATABASE_URL: postgresql+asyncpg://print3d:${DB_PASSWORD}@db:5432/print3d + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY} + TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN} + TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID} + UPLOAD_DIR: /app/uploads + volumes: + - uploads:/app/uploads + depends_on: + - db + ports: + - "8000:8000" + + frontend: + build: ./frontend + ports: + - "5173:5173" + + nginx: + image: nginx:alpine + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf + - ./nginx/certs:/etc/nginx/certs + depends_on: + - backend + - frontend + +volumes: + pgdata: + uploads: +``` + +--- + +## Переменные окружения (.env) + +``` +DB_PASSWORD= +ANTHROPIC_API_KEY= +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= +``` + +--- + +## Порядок реализации + +### Фаза 1 — Backend core (3-4 дня) +1. Инициализация FastAPI проекта, Docker, PostgreSQL +2. Модели SQLAlchemy + Alembic миграции +3. Seed данных по материалам +4. Сервис парсинга файлов (trimesh) +5. Сервис расчёта цены (price engine) +6. Endpoints: POST /api/calculate, GET /api/materials + +### Фаза 2 — Frontend core (3-4 дня) +1. Инициализация Vue 3 + Vite + Tailwind +2. FileUploader компонент +3. MaterialPicker компонент +4. PrintSettings + PriceResult компоненты +5. Интеграция с API (Pinia stores + Axios) + +### Фаза 3 — AI + Orders (2-3 дня) +1. AI Advisor — интеграция Claude API +2. AiAdvisor компонент (фронт) +3. OrderForm компонент + POST /api/orders +4. Telegram-уведомления + +### Фаза 4 — Деплой (1-2 дня) +1. Docker Compose конфигурация +2. Nginx конфиг (reverse proxy + SSL) +3. Деплой на VPS +4. Тестирование end-to-end + +--- + +## Что НЕ входит в MVP (v2) + +- 3D-превью модели в браузере (Three.js + STLLoader) +- Парсинг STEP-файлов (требует cadquery / OpenCASCADE) +- Личный кабинет клиента +- Онлайн-оплата (ЮKassa / Stripe) +- История заказов +- Тёмная тема +- Скачивание расчёта в PDF +- Мультиязычность +- SEO-оптимизация (SSR / pre-rendering) diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..f70288a --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..cb9a9e0 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,36 @@ +[alembic] +script_location = alembic +sqlalchemy.url = postgresql+asyncpg://print3d:print3d@db:5432/print3d + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..b7deac8 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,49 @@ +import asyncio +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.ext.asyncio import async_engine_from_config +from alembic import context + +from app.database import Base +from app.models import Material, Calculation, Order + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline(): + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=target_metadata, literal_binds=True) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection): + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations(): + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +def run_migrations_online(): + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..751bb55 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,23 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..3ab5cf6 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,21 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + DATABASE_URL: str = "postgresql+asyncpg://print3d:P3D_PASSWORD@31.59.58.220:5432/print3d" + GOOGLE_API_KEY: str = "" + TELEGRAM_BOT_TOKEN: str = "" + TELEGRAM_CHAT_ID: str = "" + UPLOAD_DIR: str = "/app/uploads" + MAX_FILE_SIZE_MB: int = 50 + + MINIO_ENDPOINT: str = "localhost:9000" + MINIO_ACCESS_KEY: str = "minioadmin" + MINIO_SECRET_KEY: str = "minioadmin" + MINIO_BUCKET: str = "filam3d" + MINIO_SECURE: bool = False + + model_config = {"env_file": ["../.env", ".env"]} + + +settings = Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..43081bc --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,24 @@ +import logging + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase + +from app.config import settings + +logger = logging.getLogger("app.database") + +logger.info("Initializing database engine: %s", settings.DATABASE_URL.split("@")[-1]) # log host only, not password + +engine = create_async_engine(settings.DATABASE_URL, echo=False) +async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +class Base(DeclarativeBase): + pass + + +async def get_db() -> AsyncSession: + logger.debug("Opening database session") + async with async_session() as session: + yield session + logger.debug("Database session closed") diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..dddeb82 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,90 @@ +import logging +import sys +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy import select + +from app.database import async_session, engine, Base +from app.models import Material +from app.seed.materials import MATERIALS +from app.routers import calculate, materials, orders, ai_advisor + +# Configure logging +logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s | %(levelname)-8s | %(name)-30s | %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + stream=sys.stdout, +) +logger = logging.getLogger("app.main") + +# Reduce noise from third-party libs +logging.getLogger("uvicorn.access").setLevel(logging.INFO) +logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING) +logging.getLogger("httpx").setLevel(logging.INFO) +logging.getLogger("httpcore").setLevel(logging.INFO) +logging.getLogger("trimesh").setLevel(logging.WARNING) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info("=== Application startup ===") + + logger.info("Creating database tables...") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + logger.info("Database tables created successfully") + + logger.info("Checking seed data...") + async with async_session() as session: + result = await session.execute(select(Material).limit(1)) + if result.scalar_one_or_none() is None: + logger.info("No materials found, seeding %d materials...", len(MATERIALS)) + for mat_data in MATERIALS: + session.add(Material(**mat_data)) + logger.debug(" Seeded material: %s", mat_data["name"]) + await session.commit() + logger.info("Materials seeded successfully") + else: + logger.info("Materials already exist, skipping seed") + + logger.info("=== Application ready ===") + yield + logger.info("=== Application shutdown ===") + + +app = FastAPI(title="3D Print Calculator", version="1.0.0", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.middleware("http") +async def log_requests(request: Request, call_next): + logger.info("--> %s %s (client: %s)", request.method, request.url.path, request.client.host if request.client else "unknown") + logger.debug(" Headers: %s", dict(request.headers)) + logger.debug(" Query params: %s", dict(request.query_params)) + + response = await call_next(request) + + logger.info("<-- %s %s -> %d", request.method, request.url.path, response.status_code) + return response + + +app.include_router(calculate.router, prefix="/api") +app.include_router(materials.router, prefix="/api") +app.include_router(orders.router, prefix="/api") +app.include_router(ai_advisor.router, prefix="/api") + + +@app.get("/api/health") +async def health(): + logger.debug("Health check requested") + return {"status": "ok"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..0c65c3e --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,5 @@ +from app.models.material import Material +from app.models.calculation import Calculation +from app.models.order import Order + +__all__ = ["Material", "Calculation", "Order"] diff --git a/backend/app/models/calculation.py b/backend/app/models/calculation.py new file mode 100644 index 0000000..e159be4 --- /dev/null +++ b/backend/app/models/calculation.py @@ -0,0 +1,34 @@ +import uuid +from sqlalchemy import Float, Integer, String, Boolean, ForeignKey, func +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column +from datetime import datetime + +from app.database import Base + + +class Calculation(Base): + __tablename__ = "calculations" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + file_name: Mapped[str] = mapped_column(String(255), nullable=False) + file_format: Mapped[str] = mapped_column(String(10), nullable=False) + file_path: Mapped[str | None] = mapped_column(String(500)) + volume_cm3: Mapped[float] = mapped_column(Float, nullable=False) + surface_area_cm2: Mapped[float | None] = mapped_column(Float) + bounding_box: Mapped[dict | None] = mapped_column(JSONB) + is_watertight: Mapped[bool | None] = mapped_column(Boolean) + triangle_count: Mapped[int | None] = mapped_column(Integer) + material_id: Mapped[int] = mapped_column(Integer, ForeignKey("materials.id"), nullable=False) + infill_percent: Mapped[int] = mapped_column(Integer, default=30) + layer_height_mm: Mapped[float] = mapped_column(Float, default=0.2) + quantity: Mapped[int] = mapped_column(Integer, default=1) + post_processing: Mapped[dict] = mapped_column(JSONB, default=list) + weight_grams: Mapped[float | None] = mapped_column(Float) + material_cost_rub: Mapped[float | None] = mapped_column(Float) + time_cost_rub: Mapped[float | None] = mapped_column(Float) + print_time_hours: Mapped[float | None] = mapped_column(Float) + post_processing_cost_rub: Mapped[float | None] = mapped_column(Float) + total_rub: Mapped[float | None] = mapped_column(Float) + estimated_days: Mapped[int | None] = mapped_column(Integer) + created_at: Mapped[datetime] = mapped_column(default=func.now()) diff --git a/backend/app/models/material.py b/backend/app/models/material.py new file mode 100644 index 0000000..5054d80 --- /dev/null +++ b/backend/app/models/material.py @@ -0,0 +1,28 @@ +from sqlalchemy import Boolean, Float, Integer, String, Text, func +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column +from datetime import datetime + +from app.database import Base + + +class Material(Base): + __tablename__ = "materials" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(100), nullable=False) + category: Mapped[str] = mapped_column(String(50), nullable=False) + density_g_cm3: Mapped[float] = mapped_column(Float, nullable=False) + price_per_gram: Mapped[float] = mapped_column(Float, nullable=False) + flow_rate_mm3_s: Mapped[float] = mapped_column(Float, nullable=False) + max_temp_c: Mapped[int | None] = mapped_column(Integer) + min_temp_c: Mapped[int | None] = mapped_column(Integer) + strength: Mapped[str | None] = mapped_column(String(20)) + flexibility: Mapped[str | None] = mapped_column(String(20)) + chemical_resistance: Mapped[str | None] = mapped_column(String(20)) + uv_resistance: Mapped[str | None] = mapped_column(String(20)) + food_safe: Mapped[bool] = mapped_column(Boolean, default=False) + description: Mapped[str | None] = mapped_column(Text) + color_options: Mapped[dict] = mapped_column(JSONB, default=list) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column(default=func.now()) diff --git a/backend/app/models/order.py b/backend/app/models/order.py new file mode 100644 index 0000000..1852612 --- /dev/null +++ b/backend/app/models/order.py @@ -0,0 +1,25 @@ +import uuid +from sqlalchemy import Float, Integer, String, Text, ForeignKey, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column +from datetime import datetime + +from app.database import Base + + +class Order(Base): + __tablename__ = "orders" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + order_id: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) + calculation_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("calculations.id"), nullable=False) + client_name: Mapped[str] = mapped_column(String(200), nullable=False) + client_phone: Mapped[str] = mapped_column(String(20), nullable=False) + client_email: Mapped[str | None] = mapped_column(String(200)) + client_company: Mapped[str | None] = mapped_column(String(200)) + delivery_method: Mapped[str] = mapped_column(String(50), default="pickup") + comment: Mapped[str | None] = mapped_column(Text) + status: Mapped[str] = mapped_column(String(30), default="pending") + total_rub: Mapped[float] = mapped_column(Float, nullable=False) + created_at: Mapped[datetime] = mapped_column(default=func.now()) + updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/ai_advisor.py b/backend/app/routers/ai_advisor.py new file mode 100644 index 0000000..ed26499 --- /dev/null +++ b/backend/app/routers/ai_advisor.py @@ -0,0 +1,76 @@ +import logging + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models.material import Material +from app.schemas.calculate import AdvisorRequest, AdvisorResponse, AdvisorAlternative +from app.services.ai_advisor import get_material_recommendation + +logger = logging.getLogger("app.routers.ai_advisor") + +router = APIRouter() + + +@router.post("/advisor", response_model=AdvisorResponse) +async def advisor(request: AdvisorRequest, db: AsyncSession = Depends(get_db)): + logger.info("===== POST /api/advisor =====") + logger.info("Task description: %s", request.task_description) + logger.info("Budget preference: %s", request.budget_preference) + logger.info("File info: %s", request.file_info) + + logger.info("Fetching materials from database...") + result = await db.execute(select(Material).where(Material.is_active == True).order_by(Material.id)) + materials = result.scalars().all() + logger.info("Found %d active materials", len(materials)) + + materials_data = [ + { + "id": m.id, + "name": m.name, + "category": m.category, + "density_g_cm3": m.density_g_cm3, + "price_per_gram": m.price_per_gram, + "max_temp_c": m.max_temp_c, + "min_temp_c": m.min_temp_c, + "strength": m.strength, + "flexibility": m.flexibility, + "chemical_resistance": m.chemical_resistance, + "uv_resistance": m.uv_resistance, + "food_safe": m.food_safe, + "description": m.description, + } + for m in materials + ] + + logger.info("Calling AI advisor service...") + try: + rec = await get_material_recommendation( + task_description=request.task_description, + materials_data=materials_data, + budget_preference=request.budget_preference, + file_info=request.file_info, + ) + logger.info("AI advisor returned recommendation: material_id=%s, name=%s", + rec.get("recommended_material_id"), rec.get("recommended_material_name")) + except ValueError as e: + logger.error("AI advisor ValueError: %s", str(e)) + raise HTTPException(400, str(e)) + except Exception as e: + logger.error("AI advisor unexpected error: %s", str(e), exc_info=True) + raise HTTPException(500, f"Ошибка AI-сервиса: {str(e)}") + + response = AdvisorResponse( + recommended_material_id=rec.get("recommended_material_id", 1), + recommended_material_name=rec.get("recommended_material_name", ""), + reasoning=rec.get("reasoning", ""), + alternatives=[ + AdvisorAlternative(**alt) for alt in rec.get("alternatives", []) + ], + questions=rec.get("questions", []), + ) + + logger.info("===== /api/advisor complete =====") + return response diff --git a/backend/app/routers/calculate.py b/backend/app/routers/calculate.py new file mode 100644 index 0000000..ab734cf --- /dev/null +++ b/backend/app/routers/calculate.py @@ -0,0 +1,200 @@ +import logging +import os +import tempfile +import uuid + +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database import get_db +from app.models.material import Material +from app.models.calculation import Calculation +from app.schemas.calculate import ( + BoundingBox, + CalculateResponse, + CalculationResult, + FileInfoResponse, + MaterialInfo, +) +from app.services.file_parser import FileInfo, parse_3d_file, SUPPORTED_EXTENSIONS +from app.services.price_engine import calculate_price +from app.services.storage import upload_file, delete_file + +logger = logging.getLogger("app.routers.calculate") + +router = APIRouter() + +MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB + + +@router.post("/calculate", response_model=CalculateResponse) +async def calculate( + file: UploadFile = File(...), + material_id: int = Form(...), + infill_percent: int = Form(30), + layer_height_mm: float = Form(0.2), + quantity: int = Form(1), + post_processing: str = Form(""), + db: AsyncSession = Depends(get_db), +): + logger.info("===== /api/calculate request =====") + + # Validate extension + filename = file.filename or "unknown" + ext = os.path.splitext(filename)[1].lower() + logger.info("File: name=%s, ext=%s, content_type=%s", filename, ext, file.content_type) + + if ext not in SUPPORTED_EXTENSIONS: + logger.warning("Unsupported file extension: %s (allowed: %s)", ext, SUPPORTED_EXTENSIONS) + raise HTTPException(400, f"Неподдерживаемый формат файла. Допустимые: {', '.join(SUPPORTED_EXTENSIONS)}") + + # Validate params + logger.info("Params: material_id=%d, infill=%d%%, layer=%.2fmm, qty=%d, post_processing='%s'", + material_id, infill_percent, layer_height_mm, quantity, post_processing) + + if not 10 <= infill_percent <= 100: + logger.warning("Invalid infill_percent: %d", infill_percent) + raise HTTPException(400, "infill_percent должен быть от 10 до 100") + if not 0.08 <= layer_height_mm <= 0.4: + logger.warning("Invalid layer_height_mm: %.2f", layer_height_mm) + raise HTTPException(400, "layer_height_mm должен быть от 0.08 до 0.4") + if not 1 <= quantity <= 500: + logger.warning("Invalid quantity: %d", quantity) + raise HTTPException(400, "quantity должен быть от 1 до 500") + + # Read file + logger.info("Reading uploaded file...") + content = await file.read() + file_size = len(content) + logger.info("File read: %d bytes (%.2f MB)", file_size, file_size / 1024 / 1024) + + if file_size > MAX_FILE_SIZE: + logger.warning("File too large: %d bytes (max %d)", file_size, MAX_FILE_SIZE) + raise HTTPException(413, "Файл слишком большой (максимум 50 MB)") + + # Save to temp file for parsing, then upload to MinIO + file_id = str(uuid.uuid4()) + object_name = f"uploads/{file_id}{ext}" + tmp_path = None + + logger.info("Generated file_id: %s, object_name: %s", file_id, object_name) + + try: + logger.debug("Writing to temp file for parsing...") + with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp: + tmp.write(content) + tmp_path = tmp.name + logger.debug("Temp file created: %s", tmp_path) + + logger.info("Parsing 3D file...") + file_info = parse_3d_file(tmp_path, ext) + logger.info("File parsed successfully: volume=%.2f cm3, triangles=%d, watertight=%s", + file_info.volume_cm3, file_info.triangle_count, file_info.is_watertight) + except Exception as e: + logger.error("File parsing failed: %s", str(e), exc_info=True) + raise HTTPException(400, f"Ошибка парсинга файла: {str(e)}") + finally: + if tmp_path and os.path.exists(tmp_path): + os.remove(tmp_path) + logger.debug("Temp file removed: %s", tmp_path) + + # Upload to MinIO + try: + logger.info("Uploading to MinIO: %s", object_name) + upload_file(object_name, content) + logger.info("MinIO upload complete") + except Exception as e: + logger.error("MinIO upload failed: %s", str(e), exc_info=True) + raise HTTPException(500, f"Ошибка загрузки файла в хранилище: {str(e)}") + + # Get material + logger.info("Looking up material id=%d...", material_id) + result = await db.execute(select(Material).where(Material.id == material_id)) + material = result.scalar_one_or_none() + if not material: + logger.warning("Material not found: id=%d", material_id) + raise HTTPException(400, f"Материал с id={material_id} не найден") + logger.info("Material found: id=%d, name=%s, density=%.2f, price=%.1f RUB/g", + material.id, material.name, material.density_g_cm3, material.price_per_gram) + + # Parse post_processing + pp_list = [p.strip() for p in post_processing.split(",") if p.strip()] if post_processing else [] + logger.info("Post-processing options: %s", pp_list) + + # Calculate price + logger.info("Calculating price...") + price = calculate_price( + file_info=file_info, + density_g_cm3=material.density_g_cm3, + price_per_gram=material.price_per_gram, + flow_rate_mm3_s=material.flow_rate_mm3_s, + infill_percent=infill_percent, + layer_height_mm=layer_height_mm, + quantity=quantity, + post_processing=pp_list, + ) + + # Save calculation to DB + logger.info("Saving calculation to database...") + calc = Calculation( + file_name=filename, + file_format=ext.lstrip("."), + file_path=object_name, + volume_cm3=file_info.volume_cm3, + surface_area_cm2=file_info.surface_area_cm2, + bounding_box=file_info.bounding_box_mm, + is_watertight=file_info.is_watertight, + triangle_count=file_info.triangle_count, + material_id=material.id, + infill_percent=infill_percent, + layer_height_mm=layer_height_mm, + quantity=quantity, + post_processing=pp_list, + weight_grams=price.weight_grams, + material_cost_rub=price.material_cost_rub, + time_cost_rub=price.time_cost_rub, + print_time_hours=price.print_time_hours, + post_processing_cost_rub=price.post_processing_cost_rub, + total_rub=price.total_rub, + estimated_days=price.estimated_days, + ) + db.add(calc) + await db.commit() + await db.refresh(calc) + logger.info("Calculation saved: id=%s, total=%.2f RUB", calc.id, price.total_rub) + + logger.info("===== /api/calculate complete: %s -> %.2f RUB =====", filename, price.total_rub) + + return CalculateResponse( + success=True, + calculation_id=str(calc.id), + file_info=FileInfoResponse( + filename=filename, + format=ext.lstrip("."), + volume_cm3=round(file_info.volume_cm3, 2), + surface_area_cm2=round(file_info.surface_area_cm2, 2), + bounding_box_mm=BoundingBox(**file_info.bounding_box_mm), + is_watertight=file_info.is_watertight, + triangle_count=file_info.triangle_count, + ), + calculation=CalculationResult( + material=MaterialInfo( + id=material.id, + name=material.name, + density_g_cm3=material.density_g_cm3, + price_per_gram=material.price_per_gram, + ), + weight_grams=price.weight_grams, + material_cost_rub=price.material_cost_rub, + print_time_hours=price.print_time_hours, + time_cost_rub=price.time_cost_rub, + post_processing_cost_rub=price.post_processing_cost_rub, + subtotal_rub=price.subtotal_rub, + quantity=price.quantity, + quantity_discount_percent=price.quantity_discount_percent, + total_rub=price.total_rub, + estimated_days=price.estimated_days, + ), + ) diff --git a/backend/app/routers/materials.py b/backend/app/routers/materials.py new file mode 100644 index 0000000..47d04c1 --- /dev/null +++ b/backend/app/routers/materials.py @@ -0,0 +1,52 @@ +import logging + +from fastapi import APIRouter, Depends +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models.material import Material +from app.schemas.material import MaterialResponse, MaterialProperties + +logger = logging.getLogger("app.routers.materials") + +router = APIRouter() + + +@router.get("/materials", response_model=list[MaterialResponse]) +async def get_materials(db: AsyncSession = Depends(get_db)): + logger.info("GET /api/materials") + + result = await db.execute(select(Material).where(Material.is_active == True).order_by(Material.id)) + materials = result.scalars().all() + logger.info("Found %d active materials", len(materials)) + + response = [ + MaterialResponse( + id=m.id, + name=m.name, + category=m.category, + price_per_gram=m.price_per_gram, + density_g_cm3=m.density_g_cm3, + flow_rate_mm3_s=m.flow_rate_mm3_s, + properties=MaterialProperties( + max_temp_c=m.max_temp_c, + min_temp_c=m.min_temp_c, + strength=m.strength, + flexibility=m.flexibility, + chemical_resistance=m.chemical_resistance, + uv_resistance=m.uv_resistance, + food_safe=m.food_safe, + ), + description=m.description, + color_options=m.color_options or [], + ) + for m in materials + ] + + for m in materials: + logger.debug(" Material: id=%d, name=%s, category=%s, price=%.1f RUB/g", + m.id, m.name, m.category, m.price_per_gram) + + logger.info("Returning %d materials", len(response)) + return response diff --git a/backend/app/routers/orders.py b/backend/app/routers/orders.py new file mode 100644 index 0000000..d8afba2 --- /dev/null +++ b/backend/app/routers/orders.py @@ -0,0 +1,106 @@ +import logging +import uuid +from datetime import datetime, timedelta + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models.calculation import Calculation +from app.models.material import Material +from app.models.order import Order +from app.schemas.order import OrderCreate, OrderResponse +from app.services.telegram_notify import notify_new_order + +logger = logging.getLogger("app.routers.orders") + +router = APIRouter() + + +async def generate_order_id(db: AsyncSession) -> str: + year = datetime.now().year + result = await db.execute( + select(func.count(Order.id)).where(Order.order_id.like(f"ORD-{year}-%")) + ) + count = result.scalar() or 0 + order_id = f"ORD-{year}-{count + 1:04d}" + logger.debug("Generated order_id: %s (existing count: %d)", order_id, count) + return order_id + + +@router.post("/orders", response_model=OrderResponse) +async def create_order(order_data: OrderCreate, db: AsyncSession = Depends(get_db)): + logger.info("===== POST /api/orders =====") + logger.info("Client: name=%s, phone=%s, email=%s, company=%s", + order_data.client_name, order_data.client_phone, + order_data.client_email, order_data.client_company) + logger.info("Calculation ID: %s", order_data.calculation_id) + logger.info("Delivery: %s, comment: %s", order_data.delivery_method, order_data.comment) + + # Get calculation + try: + calc_uuid = uuid.UUID(order_data.calculation_id) + except ValueError: + logger.warning("Invalid calculation_id format: %s", order_data.calculation_id) + raise HTTPException(400, "Некорректный calculation_id") + + logger.info("Looking up calculation: %s", calc_uuid) + result = await db.execute(select(Calculation).where(Calculation.id == calc_uuid)) + calc = result.scalar_one_or_none() + if not calc: + logger.warning("Calculation not found: %s", calc_uuid) + raise HTTPException(404, "Расчёт не найден") + logger.info("Calculation found: total=%.2f RUB, material_id=%d, estimated_days=%s", + calc.total_rub, calc.material_id, calc.estimated_days) + + # Get material name for notification + logger.debug("Looking up material id=%d for notification...", calc.material_id) + mat_result = await db.execute(select(Material).where(Material.id == calc.material_id)) + material = mat_result.scalar_one_or_none() + material_name = material.name if material else "Неизвестный" + logger.info("Material for notification: %s", material_name) + + order_id = await generate_order_id(db) + estimated_ready = datetime.now() + timedelta(days=calc.estimated_days or 3) + logger.info("Order ID: %s, estimated ready: %s", order_id, estimated_ready.strftime("%Y-%m-%d")) + + order = Order( + order_id=order_id, + calculation_id=calc.id, + client_name=order_data.client_name, + client_phone=order_data.client_phone, + client_email=order_data.client_email, + client_company=order_data.client_company, + delivery_method=order_data.delivery_method, + comment=order_data.comment, + total_rub=calc.total_rub, + ) + db.add(order) + await db.commit() + await db.refresh(order) + logger.info("Order saved to database: id=%d, order_id=%s", order.id, order_id) + + # Send Telegram notification + logger.info("Sending Telegram notification...") + try: + await notify_new_order( + order_id=order_id, + client_name=order_data.client_name, + client_phone=order_data.client_phone, + material_name=material_name, + total_rub=calc.total_rub, + comment=order_data.comment, + ) + logger.info("Telegram notification sent successfully") + except Exception: + logger.exception("Telegram notification failed (order still created)") + + logger.info("===== Order created: %s -> %.2f RUB =====", order_id, calc.total_rub) + + return OrderResponse( + order_id=order_id, + status="pending", + total_rub=calc.total_rub, + estimated_ready_date=estimated_ready.strftime("%Y-%m-%d"), + ) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/calculate.py b/backend/app/schemas/calculate.py new file mode 100644 index 0000000..686bd3e --- /dev/null +++ b/backend/app/schemas/calculate.py @@ -0,0 +1,65 @@ +from pydantic import BaseModel, Field + + +class BoundingBox(BaseModel): + x: float + y: float + z: float + + +class FileInfoResponse(BaseModel): + filename: str + format: str + volume_cm3: float + surface_area_cm2: float + bounding_box_mm: BoundingBox + is_watertight: bool + triangle_count: int + + +class MaterialInfo(BaseModel): + id: int + name: str + density_g_cm3: float + price_per_gram: float + + +class CalculationResult(BaseModel): + material: MaterialInfo + weight_grams: float + material_cost_rub: float + print_time_hours: float + time_cost_rub: float + post_processing_cost_rub: float + subtotal_rub: float + quantity: int + quantity_discount_percent: int + total_rub: float + estimated_days: int + + +class CalculateResponse(BaseModel): + success: bool = True + calculation_id: str + file_info: FileInfoResponse + calculation: CalculationResult + + +class AdvisorRequest(BaseModel): + task_description: str + budget_preference: str = "optimal" + file_info: dict | None = None + + +class AdvisorAlternative(BaseModel): + material_id: int + name: str + why: str + + +class AdvisorResponse(BaseModel): + recommended_material_id: int + recommended_material_name: str + reasoning: str + alternatives: list[AdvisorAlternative] = [] + questions: list[str] = [] diff --git a/backend/app/schemas/material.py b/backend/app/schemas/material.py new file mode 100644 index 0000000..6399f95 --- /dev/null +++ b/backend/app/schemas/material.py @@ -0,0 +1,25 @@ +from pydantic import BaseModel + + +class MaterialProperties(BaseModel): + max_temp_c: int | None = None + min_temp_c: int | None = None + strength: str | None = None + flexibility: str | None = None + chemical_resistance: str | None = None + uv_resistance: str | None = None + food_safe: bool = False + + +class MaterialResponse(BaseModel): + id: int + name: str + category: str + price_per_gram: float + density_g_cm3: float + flow_rate_mm3_s: float + properties: MaterialProperties + description: str | None = None + color_options: list[str] = [] + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/order.py b/backend/app/schemas/order.py new file mode 100644 index 0000000..68830c8 --- /dev/null +++ b/backend/app/schemas/order.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel, Field + + +class OrderCreate(BaseModel): + calculation_id: str + client_name: str + client_phone: str = Field(pattern=r"^\+?\d{10,15}$") + client_email: str | None = None + client_company: str | None = None + delivery_method: str = "pickup" + comment: str | None = None + + +class OrderResponse(BaseModel): + order_id: str + status: str + total_rub: float + estimated_ready_date: str diff --git a/backend/app/seed/__init__.py b/backend/app/seed/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/seed/materials.py b/backend/app/seed/materials.py new file mode 100644 index 0000000..f7ee3a5 --- /dev/null +++ b/backend/app/seed/materials.py @@ -0,0 +1,114 @@ +MATERIALS = [ + { + "name": "PLA", + "category": "basic", + "density_g_cm3": 1.24, + "price_per_gram": 25.0, + "flow_rate_mm3_s": 15.0, + "max_temp_c": 60, + "min_temp_c": -20, + "strength": "medium", + "flexibility": "low", + "chemical_resistance": "low", + "uv_resistance": "low", + "food_safe": True, + "description": "Базовый пластик. Лёгкий в печати, хорошая детализация. Для прототипов и декора.", + "color_options": ["white", "black", "gray", "red", "blue", "green", "natural"], + }, + { + "name": "PETG", + "category": "basic", + "density_g_cm3": 1.27, + "price_per_gram": 28.0, + "flow_rate_mm3_s": 12.0, + "max_temp_c": 80, + "min_temp_c": -40, + "strength": "high", + "flexibility": "medium", + "chemical_resistance": "medium", + "uv_resistance": "medium", + "food_safe": True, + "description": "Универсальный инженерный пластик. Прочный, химстойкий, подходит для улицы.", + "color_options": ["white", "black", "gray", "natural", "blue"], + }, + { + "name": "ABS", + "category": "basic", + "density_g_cm3": 1.04, + "price_per_gram": 25.0, + "flow_rate_mm3_s": 12.0, + "max_temp_c": 100, + "min_temp_c": -30, + "strength": "high", + "flexibility": "low", + "chemical_resistance": "medium", + "uv_resistance": "low", + "food_safe": False, + "description": "Термостойкий, ударопрочный. Требует закрытой камеры. Обрабатывается ацетоном.", + "color_options": ["white", "black", "gray", "red", "blue"], + }, + { + "name": "PA (Nylon)", + "category": "engineering", + "density_g_cm3": 1.14, + "price_per_gram": 50.0, + "flow_rate_mm3_s": 10.0, + "max_temp_c": 120, + "min_temp_c": -40, + "strength": "very_high", + "flexibility": "medium", + "chemical_resistance": "high", + "uv_resistance": "medium", + "food_safe": False, + "description": "Инженерный пластик. Высокая прочность, износостойкость. Для шестерён, креплений.", + "color_options": ["natural", "black"], + }, + { + "name": "PC (Поликарбонат)", + "category": "engineering", + "density_g_cm3": 1.20, + "price_per_gram": 60.0, + "flow_rate_mm3_s": 8.0, + "max_temp_c": 140, + "min_temp_c": -40, + "strength": "very_high", + "flexibility": "low", + "chemical_resistance": "high", + "uv_resistance": "high", + "food_safe": False, + "description": "Максимальная термостойкость и прочность. Для корпусов, работающих при высоких температурах.", + "color_options": ["natural", "black"], + }, + { + "name": "TPU", + "category": "engineering", + "density_g_cm3": 1.21, + "price_per_gram": 40.0, + "flow_rate_mm3_s": 6.0, + "max_temp_c": 80, + "min_temp_c": -30, + "strength": "medium", + "flexibility": "very_high", + "chemical_resistance": "high", + "uv_resistance": "medium", + "food_safe": False, + "description": "Эластичный пластик, аналог резины. Для прокладок, амортизаторов, гибких деталей.", + "color_options": ["white", "black", "natural"], + }, + { + "name": "PA-CF (Нейлон + углеволокно)", + "category": "composite", + "density_g_cm3": 1.18, + "price_per_gram": 75.0, + "flow_rate_mm3_s": 8.0, + "max_temp_c": 150, + "min_temp_c": -40, + "strength": "extreme", + "flexibility": "low", + "chemical_resistance": "very_high", + "uv_resistance": "high", + "food_safe": False, + "description": "Композит с углеволокном. Максимальная жёсткость и прочность. Замена алюминия.", + "color_options": ["black"], + }, +] diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/ai_advisor.py b/backend/app/services/ai_advisor.py new file mode 100644 index 0000000..3361fe1 --- /dev/null +++ b/backend/app/services/ai_advisor.py @@ -0,0 +1,104 @@ +import json +import logging +import time + +from google import genai +from google.genai import types + +from app.config import settings + +logger = logging.getLogger("app.services.ai_advisor") + +SYSTEM_PROMPT = """ +Ты — эксперт по 3D-печати из инженерных пластиков по технологии FDM. +Твоя задача — рекомендовать оптимальный материал для печати на основе описания задачи клиента. + +Доступные материалы: +{materials_json} + +Правила: +1. Всегда рекомендуй один основной материал и 1-2 альтернативы. +2. Учитывай: температурный режим, механические нагрузки, химическое воздействие, UV, влажность. +3. Если задача не подходит для FDM-печати (слишком мелкие детали, высокая точность) — честно скажи об этом. +4. Отвечай кратко, по делу, на русском языке. +5. Если клиент не указал критичные параметры — задай уточняющие вопросы. + +Формат ответа — строго JSON: +{{ + "recommended_material_id": , + "recommended_material_name": "", + "reasoning": "<обоснование на русском>", + "alternatives": [{{"material_id": , "name": "", "why": "<причина>"}}], + "questions": ["<вопрос, если нужна доп. информация>"] +}} +""" + + +async def get_material_recommendation( + task_description: str, + materials_data: list[dict], + budget_preference: str = "optimal", + file_info: dict | None = None, +) -> dict: + """Get material recommendation from Google Gemini API.""" + logger.info("=== AI Advisor request ===") + logger.info("Task: %s", task_description) + logger.info("Budget preference: %s", budget_preference) + logger.info("File info: %s", file_info) + logger.info("Materials count: %d", len(materials_data)) + + if not settings.GOOGLE_API_KEY: + logger.error("GOOGLE_API_KEY is not configured") + raise ValueError("GOOGLE_API_KEY not configured") + + materials_json = json.dumps(materials_data, ensure_ascii=False, indent=2) + system = SYSTEM_PROMPT.format(materials_json=materials_json) + logger.debug("System prompt length: %d chars", len(system)) + + user_message = f"Описание задачи: {task_description}\nПредпочтение по бюджету: {budget_preference}" + if file_info: + user_message += f"\nИнформация о модели: {json.dumps(file_info, ensure_ascii=False)}" + logger.debug("User message: %s", user_message) + + logger.info("Sending request to Gemini API (model: gemini-2.0-flash)...") + start_time = time.time() + + client = genai.Client(api_key=settings.GOOGLE_API_KEY) + response = await client.aio.models.generate_content( + model="gemini-3-flash-preview", + contents=user_message, + config=types.GenerateContentConfig( + system_instruction=system, + max_output_tokens=1024, + temperature=0.3, + ), + ) + + elapsed = time.time() - start_time + logger.info("Gemini API responded in %.2f seconds", elapsed) + + response_text = response.text + logger.debug("Raw response (%d chars): %s", len(response_text), response_text[:500]) + + try: + result = json.loads(response_text) + logger.info("Response parsed as JSON successfully") + logger.info("Recommended material: id=%s, name=%s", + result.get("recommended_material_id"), result.get("recommended_material_name")) + logger.info("Alternatives: %d, Questions: %d", + len(result.get("alternatives", [])), len(result.get("questions", []))) + logger.info("=== AI Advisor complete ===") + return result + except json.JSONDecodeError: + logger.warning("Direct JSON parse failed, trying to extract JSON from response...") + start = response_text.find("{") + end = response_text.rfind("}") + 1 + if start != -1 and end > start: + extracted = response_text[start:end] + logger.debug("Extracted JSON substring [%d:%d]: %s", start, end, extracted[:300]) + result = json.loads(extracted) + logger.info("Extracted JSON parsed successfully") + logger.info("=== AI Advisor complete ===") + return result + logger.error("Failed to extract JSON from AI response: %s", response_text[:200]) + raise ValueError("AI вернул невалидный JSON") diff --git a/backend/app/services/file_parser.py b/backend/app/services/file_parser.py new file mode 100644 index 0000000..7b7240a --- /dev/null +++ b/backend/app/services/file_parser.py @@ -0,0 +1,62 @@ +import logging +from dataclasses import dataclass + +import trimesh + +logger = logging.getLogger("app.services.file_parser") + + +@dataclass +class FileInfo: + volume_cm3: float + surface_area_cm2: float + bounding_box_mm: dict[str, float] + is_watertight: bool + triangle_count: int + + +SUPPORTED_EXTENSIONS = {".stl", ".3mf", ".obj"} + + +def parse_3d_file(file_path: str, file_extension: str) -> FileInfo: + """Parse a 3D file and return geometric properties.""" + ext = file_extension.lower().lstrip(".") + logger.info("Parsing 3D file: path=%s, extension=%s", file_path, ext) + + logger.debug("Loading mesh with trimesh (file_type=%s)...", ext) + mesh = trimesh.load(file_path, file_type=ext) + logger.debug("Trimesh loaded object type: %s", type(mesh).__name__) + + if isinstance(mesh, trimesh.Scene): + meshes = list(mesh.dump()) + logger.info("File is a Scene with %d geometries, concatenating...", len(meshes)) + if not meshes: + logger.error("Scene contains no geometries") + raise ValueError("Файл не содержит 3D-геометрии") + mesh = trimesh.util.concatenate(meshes) + logger.debug("Concatenated into single Trimesh") + + if not isinstance(mesh, trimesh.Trimesh): + logger.error("Could not extract Trimesh object, got: %s", type(mesh).__name__) + raise ValueError("Не удалось извлечь 3D-геометрию из файла") + + volume_cm3 = abs(mesh.volume) / 1000.0 + surface_area_cm2 = mesh.area / 100.0 + bbox = { + "x": round(float(mesh.bounding_box.extents[0]), 2), + "y": round(float(mesh.bounding_box.extents[1]), 2), + "z": round(float(mesh.bounding_box.extents[2]), 2), + } + is_watertight = bool(mesh.is_watertight) + triangle_count = len(mesh.faces) + + logger.info("Parse result: volume=%.2f cm3, area=%.2f cm2, bbox=%s, watertight=%s, triangles=%d", + volume_cm3, surface_area_cm2, bbox, is_watertight, triangle_count) + + return FileInfo( + volume_cm3=volume_cm3, + surface_area_cm2=surface_area_cm2, + bounding_box_mm=bbox, + is_watertight=is_watertight, + triangle_count=triangle_count, + ) diff --git a/backend/app/services/price_engine.py b/backend/app/services/price_engine.py new file mode 100644 index 0000000..32480c8 --- /dev/null +++ b/backend/app/services/price_engine.py @@ -0,0 +1,140 @@ +import logging +from dataclasses import dataclass +from app.services.file_parser import FileInfo + +logger = logging.getLogger("app.services.price_engine") + +TIME_RATE_PER_HOUR = 200.0 # руб/час +SETUP_TIME_MIN = 15.0 # минуты +TRAVEL_TIME_PER_LAYER_MIN = 0.3 + +POST_PROCESSING_COSTS = { + "sanding": 300.0, + "painting": 500.0, + "threading": 200.0, + "acetone_smoothing": 400.0, +} + +QUANTITY_DISCOUNTS = [ + (1, 0), + (2, 5), + (6, 10), + (21, 15), + (101, 20), +] + + +@dataclass +class PriceResult: + weight_grams: float + material_cost_rub: float + print_time_hours: float + time_cost_rub: float + post_processing_cost_rub: float + subtotal_rub: float + quantity: int + quantity_discount_percent: int + total_rub: float + estimated_days: int + + +def get_quantity_discount(quantity: int) -> int: + discount = 0 + for min_qty, disc in QUANTITY_DISCOUNTS: + if quantity >= min_qty: + discount = disc + logger.debug("Quantity discount for %d pcs: %d%%", quantity, discount) + return discount + + +def estimate_print_time(file_info: FileInfo, layer_height_mm: float, flow_rate_mm3_s: float) -> float: + """Estimate print time in hours.""" + z_height = file_info.bounding_box_mm.get("z", 10.0) + layers = max(z_height / layer_height_mm, 1) + volume_mm3 = file_info.volume_cm3 * 1000.0 + volume_per_layer = volume_mm3 / layers + time_per_layer_min = volume_per_layer / flow_rate_mm3_s / 60.0 + total_min = layers * (time_per_layer_min + TRAVEL_TIME_PER_LAYER_MIN) + SETUP_TIME_MIN + hours = round(total_min / 60.0, 1) + logger.debug("Print time estimate: z=%.1fmm, layers=%.0f, vol_per_layer=%.1fmm3, " + "time_per_layer=%.2fmin, total=%.1fmin (%.1fh)", + z_height, layers, volume_per_layer, time_per_layer_min, total_min, hours) + return hours + + +def calculate_price( + file_info: FileInfo, + density_g_cm3: float, + price_per_gram: float, + flow_rate_mm3_s: float, + infill_percent: int = 30, + layer_height_mm: float = 0.2, + quantity: int = 1, + post_processing: list[str] | None = None, +) -> PriceResult: + post_processing = post_processing or [] + + logger.info("=== Price calculation start ===") + logger.info("Input: volume=%.2f cm3, density=%.2f g/cm3, price_per_gram=%.1f RUB", + file_info.volume_cm3, density_g_cm3, price_per_gram) + logger.info("Params: infill=%d%%, layer=%.2fmm, qty=%d, post_processing=%s", + infill_percent, layer_height_mm, quantity, post_processing) + + effective_volume = file_info.volume_cm3 * (infill_percent / 100.0) * 0.7 + file_info.volume_cm3 * 0.3 + logger.debug("Effective volume: %.2f cm3 (infill-scaled: %.2f + walls: %.2f)", + effective_volume, + file_info.volume_cm3 * (infill_percent / 100.0) * 0.7, + file_info.volume_cm3 * 0.3) + + weight_g = round(effective_volume * density_g_cm3, 1) + material_cost = round(weight_g * price_per_gram, 2) + logger.debug("Weight: %.1f g, material cost: %.2f RUB", weight_g, material_cost) + + print_time_h = estimate_print_time(file_info, layer_height_mm, flow_rate_mm3_s) + time_cost = round(print_time_h * TIME_RATE_PER_HOUR, 2) + logger.debug("Print time: %.1f h, time cost: %.2f RUB (rate: %.0f RUB/h)", print_time_h, time_cost, TIME_RATE_PER_HOUR) + + pp_cost = 0.0 + for pp in post_processing: + cost = POST_PROCESSING_COSTS.get(pp, 0) + logger.debug("Post-processing '%s': %.0f RUB", pp, cost) + pp_cost += cost + pp_cost = round(pp_cost, 2) + logger.debug("Total post-processing cost: %.2f RUB", pp_cost) + + subtotal = round(material_cost + time_cost + pp_cost, 2) + logger.debug("Subtotal (1 pc): %.2f RUB = material(%.2f) + time(%.2f) + pp(%.2f)", + subtotal, material_cost, time_cost, pp_cost) + + discount_pct = get_quantity_discount(quantity) + total = round(subtotal * quantity * (1 - discount_pct / 100.0), 2) + logger.info("Total: %.2f RUB (qty=%d, discount=%d%%, subtotal_per_unit=%.2f)", + total, quantity, discount_pct, subtotal) + + if print_time_h <= 2: + estimated_days = 2 + elif print_time_h <= 8: + estimated_days = 3 + else: + estimated_days = 5 + + if quantity > 10: + estimated_days += 2 + if quantity > 50: + estimated_days += 3 + + logger.info("Estimated days: %d", estimated_days) + logger.info("=== Price calculation complete ===") + + return PriceResult( + weight_grams=weight_g, + material_cost_rub=material_cost, + print_time_hours=print_time_h, + time_cost_rub=time_cost, + post_processing_cost_rub=pp_cost, + subtotal_rub=subtotal, + quantity=quantity, + quantity_discount_percent=discount_pct, + total_rub=total, + estimated_days=estimated_days, + ) diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py new file mode 100644 index 0000000..a9d8dde --- /dev/null +++ b/backend/app/services/storage.py @@ -0,0 +1,67 @@ +import io +import logging + +from minio import Minio +from minio.error import S3Error + +from app.config import settings + +logger = logging.getLogger("app.services.storage") + +_client: Minio | None = None + + +def get_minio_client() -> Minio: + global _client + if _client is None: + logger.info("Initializing MinIO client: endpoint=%s, secure=%s", settings.MINIO_ENDPOINT, settings.MINIO_SECURE) + _client = Minio( + endpoint=settings.MINIO_ENDPOINT, + access_key=settings.MINIO_ACCESS_KEY, + secret_key=settings.MINIO_SECRET_KEY, + secure=settings.MINIO_SECURE, + ) + logger.info("MinIO client created, checking bucket '%s'...", settings.MINIO_BUCKET) + if not _client.bucket_exists(settings.MINIO_BUCKET): + _client.make_bucket(settings.MINIO_BUCKET) + logger.info("Created MinIO bucket: %s", settings.MINIO_BUCKET) + else: + logger.info("MinIO bucket '%s' already exists", settings.MINIO_BUCKET) + return _client + + +def upload_file(object_name: str, data: bytes, content_type: str = "application/octet-stream") -> str: + """Upload file to MinIO. Returns the object name (key).""" + logger.info("Uploading to MinIO: object=%s, size=%d bytes, content_type=%s", object_name, len(data), content_type) + client = get_minio_client() + client.put_object( + bucket_name=settings.MINIO_BUCKET, + object_name=object_name, + data=io.BytesIO(data), + length=len(data), + content_type=content_type, + ) + logger.info("Upload complete: %s/%s", settings.MINIO_BUCKET, object_name) + return object_name + + +def download_file(object_name: str) -> bytes: + """Download file from MinIO.""" + logger.info("Downloading from MinIO: %s/%s", settings.MINIO_BUCKET, object_name) + client = get_minio_client() + response = client.get_object(settings.MINIO_BUCKET, object_name) + try: + data = response.read() + logger.info("Download complete: %s, size=%d bytes", object_name, len(data)) + return data + finally: + response.close() + response.release_conn() + + +def delete_file(object_name: str) -> None: + """Delete file from MinIO.""" + logger.info("Deleting from MinIO: %s/%s", settings.MINIO_BUCKET, object_name) + client = get_minio_client() + client.remove_object(settings.MINIO_BUCKET, object_name) + logger.info("Deleted: %s", object_name) diff --git a/backend/app/services/telegram_notify.py b/backend/app/services/telegram_notify.py new file mode 100644 index 0000000..5c48eea --- /dev/null +++ b/backend/app/services/telegram_notify.py @@ -0,0 +1,48 @@ +import logging + +import httpx + +from app.config import settings + +logger = logging.getLogger("app.services.telegram") + + +async def notify_new_order( + order_id: str, + client_name: str, + client_phone: str, + material_name: str, + total_rub: float, + comment: str | None = None, +) -> None: + """Send a notification about a new order to Telegram.""" + logger.info("=== Telegram notification ===") + logger.info("Order: %s, client: %s, phone: %s, material: %s, total: %.2f RUB", + order_id, client_name, client_phone, material_name, total_rub) + + if not settings.TELEGRAM_BOT_TOKEN or not settings.TELEGRAM_CHAT_ID: + logger.warning("Telegram credentials not configured (token=%s, chat_id=%s), skipping", + "set" if settings.TELEGRAM_BOT_TOKEN else "empty", + "set" if settings.TELEGRAM_CHAT_ID else "empty") + return + + text = ( + f"\U0001f195 Новый заказ #{order_id}\n" + f"Клиент: {client_name}\n" + f"Телефон: {client_phone}\n" + f"Материал: {material_name}\n" + f"Сумма: {total_rub} \u20bd\n" + f"Комментарий: {comment or '\u2014'}" + ) + + url = f"https://api.telegram.org/bot{settings.TELEGRAM_BOT_TOKEN}/sendMessage" + logger.debug("Sending to Telegram: chat_id=%s, text_length=%d", settings.TELEGRAM_CHAT_ID, len(text)) + + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post(url, json={"chat_id": settings.TELEGRAM_CHAT_ID, "text": text}) + logger.info("Telegram response: status=%d, body=%s", resp.status_code, resp.text[:200]) + if resp.status_code != 200: + logger.error("Telegram API error: %s", resp.text) + except Exception: + logger.exception("Failed to send Telegram notification") diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..036699f --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,13 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +sqlalchemy[asyncio]==2.0.36 +asyncpg==0.30.0 +pydantic-settings==2.7.1 +alembic==1.14.1 +trimesh==4.5.3 +numpy-stl==3.1.2 +numpy==2.2.1 +python-multipart==0.0.20 +httpx==0.28.1 +minio==7.2.12 +google-genai==1.14.0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0673d70 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +version: "3.8" + +services: + backend: + build: ./backend + network_mode: host + environment: + DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://print3d:print3d_secret@localhost:5432/print3d} + GOOGLE_API_KEY: ${GOOGLE_API_KEY:-} + TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-} + TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-} + MINIO_ENDPOINT: localhost:9000 + MINIO_ACCESS_KEY: admin + MINIO_SECRET_KEY: SuperSecretPassword123! + MINIO_BUCKET: ${MINIO_BUCKET:-filam3d} + MINIO_SECURE: ${MINIO_SECURE:-false} + + frontend: + build: ./frontend + network_mode: host diff --git a/frontend/.env.development b/frontend/.env.development new file mode 100644 index 0000000..6d9a11f --- /dev/null +++ b/frontend/.env.development @@ -0,0 +1 @@ +VITE_API_BASE_URL=http://localhost:8091/api diff --git a/frontend/.env.production b/frontend/.env.production new file mode 100644 index 0000000..14ea4ad --- /dev/null +++ b/frontend/.env.production @@ -0,0 +1 @@ +VITE_API_BASE_URL=/api diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..597940a --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package.json . +RUN npm install + +COPY . . + +EXPOSE 5173 + +CMD ["npm", "run", "dev"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..a759158 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + + + + Filam3D — Калькулятор 3D-печати + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..b056dbf --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2727 @@ +{ + "name": "filam3d-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "filam3d-frontend", + "version": "1.0.0", + "dependencies": { + "axios": "^1.7.9", + "pinia": "^2.3.0", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "vite": "^6.0.7" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz", + "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.30", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz", + "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz", + "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.30", + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz", + "integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz", + "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz", + "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz", + "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/runtime-core": "3.5.30", + "@vue/shared": "3.5.30", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz", + "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "vue": "3.5.30" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz", + "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==", + "license": "MIT" + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.10", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz", + "integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001780", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", + "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.321", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", + "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vue": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz", + "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-sfc": "3.5.30", + "@vue/runtime-dom": "3.5.30", + "@vue/server-renderer": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..fc5eea1 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "filam3d-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.5.13", + "vue-router": "^4.5.0", + "pinia": "^2.3.0", + "axios": "^1.7.9" + }, + "devDependencies": { + "vite": "^6.0.7", + "@vitejs/plugin-vue": "^5.2.1", + "tailwindcss": "^3.4.17", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..10461c0 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,35 @@ + diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js new file mode 100644 index 0000000..734846f --- /dev/null +++ b/frontend/src/api/client.js @@ -0,0 +1,8 @@ +import axios from 'axios' + +const api = axios.create({ + baseURL: import.meta.env.VITE_API_BASE_URL || '/api', + timeout: 60000, +}) + +export default api diff --git a/frontend/src/assets/styles/main.css b/frontend/src/assets/styles/main.css new file mode 100644 index 0000000..86fa576 --- /dev/null +++ b/frontend/src/assets/styles/main.css @@ -0,0 +1,27 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + body { + @apply min-h-screen; + } +} + +@layer components { + .btn-primary { + @apply inline-flex items-center justify-center rounded-lg bg-primary-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed; + } + + .btn-secondary { + @apply inline-flex items-center justify-center rounded-lg border border-gray-300 bg-white px-5 py-2.5 text-sm font-semibold text-gray-700 shadow-sm transition-all hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2; + } + + .card { + @apply rounded-xl border border-gray-200 bg-white p-6 shadow-sm; + } + + .input-field { + @apply block w-full rounded-lg border border-gray-300 px-3.5 py-2.5 text-sm shadow-sm transition-colors placeholder:text-gray-400 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500; + } +} diff --git a/frontend/src/components/AiAdvisor.vue b/frontend/src/components/AiAdvisor.vue new file mode 100644 index 0000000..af74561 --- /dev/null +++ b/frontend/src/components/AiAdvisor.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/frontend/src/components/FileUploader.vue b/frontend/src/components/FileUploader.vue new file mode 100644 index 0000000..fe84909 --- /dev/null +++ b/frontend/src/components/FileUploader.vue @@ -0,0 +1,125 @@ + + + diff --git a/frontend/src/components/MaterialPicker.vue b/frontend/src/components/MaterialPicker.vue new file mode 100644 index 0000000..1480d8c --- /dev/null +++ b/frontend/src/components/MaterialPicker.vue @@ -0,0 +1,82 @@ + + + diff --git a/frontend/src/components/OrderForm.vue b/frontend/src/components/OrderForm.vue new file mode 100644 index 0000000..fcb373c --- /dev/null +++ b/frontend/src/components/OrderForm.vue @@ -0,0 +1,77 @@ + + + diff --git a/frontend/src/components/PriceResult.vue b/frontend/src/components/PriceResult.vue new file mode 100644 index 0000000..a9b9051 --- /dev/null +++ b/frontend/src/components/PriceResult.vue @@ -0,0 +1,80 @@ + + + diff --git a/frontend/src/components/PrintSettings.vue b/frontend/src/components/PrintSettings.vue new file mode 100644 index 0000000..836c391 --- /dev/null +++ b/frontend/src/components/PrintSettings.vue @@ -0,0 +1,100 @@ + + + diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..f266ec6 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,10 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' +import router from './router' +import './assets/styles/main.css' + +const app = createApp(App) +app.use(createPinia()) +app.use(router) +app.mount('#app') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..15f69d7 --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,15 @@ +import { createRouter, createWebHistory } from 'vue-router' +import CalculatorView from '../views/CalculatorView.vue' +import MaterialsView from '../views/MaterialsView.vue' +import OrderView from '../views/OrderView.vue' + +const routes = [ + { path: '/', name: 'calculator', component: CalculatorView }, + { path: '/materials', name: 'materials', component: MaterialsView }, + { path: '/order/:calcId', name: 'order', component: OrderView }, +] + +export default createRouter({ + history: createWebHistory(), + routes, +}) diff --git a/frontend/src/stores/calculator.js b/frontend/src/stores/calculator.js new file mode 100644 index 0000000..e9d02eb --- /dev/null +++ b/frontend/src/stores/calculator.js @@ -0,0 +1,63 @@ +import { defineStore } from 'pinia' +import { ref, reactive } from 'vue' +import api from '../api/client' + +export const useCalculatorStore = defineStore('calculator', () => { + const file = ref(null) + const materialId = ref(null) + const settings = reactive({ + infill_percent: 30, + layer_height_mm: 0.2, + quantity: 1, + post_processing: [], + }) + const result = ref(null) + const loading = ref(false) + const error = ref(null) + const uploadProgress = ref(0) + + async function calculate() { + if (!file.value || !materialId.value) return + + loading.value = true + error.value = null + uploadProgress.value = 0 + + const formData = new FormData() + formData.append('file', file.value) + formData.append('material_id', materialId.value) + formData.append('infill_percent', settings.infill_percent) + formData.append('layer_height_mm', settings.layer_height_mm) + formData.append('quantity', settings.quantity) + formData.append('post_processing', settings.post_processing.join(',')) + + try { + const { data } = await api.post('/calculate', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + onUploadProgress: (e) => { + uploadProgress.value = Math.round((e.loaded / e.total) * 100) + }, + }) + result.value = data + } catch (e) { + error.value = e.response?.data?.detail || 'Ошибка расчёта' + result.value = null + } finally { + loading.value = false + } + } + + function reset() { + file.value = null + materialId.value = null + settings.infill_percent = 30 + settings.layer_height_mm = 0.2 + settings.quantity = 1 + settings.post_processing = [] + result.value = null + error.value = null + uploadProgress.value = 0 + } + + return { file, materialId, settings, result, loading, error, uploadProgress, calculate, reset } +}) diff --git a/frontend/src/stores/materials.js b/frontend/src/stores/materials.js new file mode 100644 index 0000000..463118a --- /dev/null +++ b/frontend/src/stores/materials.js @@ -0,0 +1,31 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import api from '../api/client' + +export const useMaterialsStore = defineStore('materials', () => { + const materials = ref([]) + const loading = ref(false) + + async function fetchMaterials() { + if (materials.value.length > 0) return + loading.value = true + try { + const { data } = await api.get('/materials') + materials.value = data + } finally { + loading.value = false + } + } + + function getMaterialById(id) { + return materials.value.find((m) => m.id === id) + } + + const categories = { + basic: 'Базовые', + engineering: 'Инженерные', + composite: 'Композитные', + } + + return { materials, loading, fetchMaterials, getMaterialById, categories } +}) diff --git a/frontend/src/views/CalculatorView.vue b/frontend/src/views/CalculatorView.vue new file mode 100644 index 0000000..12524f2 --- /dev/null +++ b/frontend/src/views/CalculatorView.vue @@ -0,0 +1,58 @@ + + + diff --git a/frontend/src/views/MaterialsView.vue b/frontend/src/views/MaterialsView.vue new file mode 100644 index 0000000..25eaf71 --- /dev/null +++ b/frontend/src/views/MaterialsView.vue @@ -0,0 +1,62 @@ + + + diff --git a/frontend/src/views/OrderView.vue b/frontend/src/views/OrderView.vue new file mode 100644 index 0000000..d73aed2 --- /dev/null +++ b/frontend/src/views/OrderView.vue @@ -0,0 +1,45 @@ + + + diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..a4da820 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,26 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{vue,js,ts}'], + theme: { + extend: { + colors: { + primary: { + 50: '#eff6ff', + 100: '#dbeafe', + 200: '#bfdbfe', + 300: '#93c5fd', + 400: '#60a5fa', + 500: '#3b82f6', + 600: '#2563eb', + 700: '#1d4ed8', + 800: '#1e40af', + 900: '#1e3a8a', + }, + }, + fontFamily: { + sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'], + }, + }, + }, + plugins: [], +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..6db2aa6 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + host: '0.0.0.0', + port: 5173, + }, +}) diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..01264de --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,37 @@ +events { + worker_connections 1024; +} + +http { + client_max_body_size 55M; + + upstream backend { + server backend:8000; + } + + upstream frontend { + server frontend:5173; + } + + server { + listen 80; + server_name _; + + location /api/ { + proxy_pass http://backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120s; + } + + location / { + proxy_pass http://frontend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + } +}