init
This commit is contained in:
6
frontend/public/robots.txt
Normal file
6
frontend/public/robots.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Sitemap: https://filam3d.ru/sitemap.xml
|
||||
|
||||
# Не индексировать страницы заказов
|
||||
Disallow: /order/
|
||||
58
frontend/public/sitemap.xml
Normal file
58
frontend/public/sitemap.xml
Normal file
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://filam3d.ru/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://filam3d.ru/materials</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://filam3d.ru/blog</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://filam3d.ru/blog/chto-takoe-fdm-pechat</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://filam3d.ru/blog/sravnenie-materialov-pla-petg-abs</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://filam3d.ru/blog/inzhenernye-plastiki-nylon-polikarbonat</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://filam3d.ru/blog/kak-podgotovit-model-dlya-3d-pechati</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://filam3d.ru/blog/3d-pechat-korpusov-dlya-elektroniki</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://filam3d.ru/blog/postobrabotka-3d-pechatnyh-detalej</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://filam3d.ru/blog/skolko-stoit-3d-pechat</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://filam3d.ru/blog/b2b-3d-pechat-dlya-biznesa</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
33
frontend/src/components/HeroSection.vue
Normal file
33
frontend/src/components/HeroSection.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<section class="mb-10 rounded-2xl bg-gradient-to-br from-primary-600 to-primary-800 p-8 sm:p-12 text-white">
|
||||
<div class="max-w-2xl">
|
||||
<h1 class="text-3xl sm:text-4xl font-bold leading-tight mb-4">
|
||||
3D-печать на заказ<br />с мгновенным расчётом
|
||||
</h1>
|
||||
<p class="text-base sm:text-lg text-primary-100 leading-relaxed mb-6">
|
||||
Загрузите 3D-модель — получите точную стоимость за секунды.
|
||||
7 материалов, AI-подбор, от прототипа до серии в 500 штук.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="h-5 w-5 text-primary-200" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span class="text-sm text-primary-100">Расчёт за секунды</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="h-5 w-5 text-primary-200" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
|
||||
</svg>
|
||||
<span class="text-sm text-primary-100">AI-подбор материала</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="h-5 w-5 text-primary-200" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6.429 9.75L2.25 12l4.179 2.25m0-4.5l5.571 3 5.571-3m-11.142 0L2.25 7.5 12 2.25l9.75 5.25-4.179 2.25m0 0L12 12.75 6.429 9.75m11.142 0l4.179 2.25-4.179 2.25m0 0L12 17.25l-5.571-3" />
|
||||
</svg>
|
||||
<span class="text-sm text-primary-100">7 материалов</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
66
frontend/src/components/SiteFooter.vue
Normal file
66
frontend/src/components/SiteFooter.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<footer class="mt-16 border-t border-gray-200 bg-white">
|
||||
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6">
|
||||
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<!-- Brand -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2.5 mb-3">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-600">
|
||||
<svg class="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-base font-bold text-gray-900">Filam3D</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 leading-relaxed">
|
||||
Сервис 3D-печати на заказ. Мгновенный расчёт стоимости, 7 материалов, AI-подбор, доставка по России.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Services -->
|
||||
<div>
|
||||
<h3 class="mb-3 text-sm font-semibold text-gray-900">Сервис</h3>
|
||||
<ul class="space-y-2">
|
||||
<li><router-link to="/" class="text-sm text-gray-500 hover:text-primary-600 transition-colors">Калькулятор</router-link></li>
|
||||
<li><router-link to="/materials" class="text-sm text-gray-500 hover:text-primary-600 transition-colors">Материалы</router-link></li>
|
||||
<li><router-link to="/blog" class="text-sm text-gray-500 hover:text-primary-600 transition-colors">Блог</router-link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Popular articles -->
|
||||
<div>
|
||||
<h3 class="mb-3 text-sm font-semibold text-gray-900">Популярное</h3>
|
||||
<ul class="space-y-2">
|
||||
<li><router-link to="/blog/sravnenie-materialov-pla-petg-abs" class="text-sm text-gray-500 hover:text-primary-600 transition-colors">PLA vs PETG vs ABS</router-link></li>
|
||||
<li><router-link to="/blog/skolko-stoit-3d-pechat" class="text-sm text-gray-500 hover:text-primary-600 transition-colors">Стоимость 3D-печати</router-link></li>
|
||||
<li><router-link to="/blog/kak-podgotovit-model-dlya-3d-pechati" class="text-sm text-gray-500 hover:text-primary-600 transition-colors">Подготовка модели</router-link></li>
|
||||
<li><router-link to="/blog/3d-pechat-korpusov-dlya-elektroniki" class="text-sm text-gray-500 hover:text-primary-600 transition-colors">Корпуса для электроники</router-link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Materials -->
|
||||
<div>
|
||||
<h3 class="mb-3 text-sm font-semibold text-gray-900">Материалы</h3>
|
||||
<ul class="space-y-2">
|
||||
<li class="text-sm text-gray-500">PLA — от 25 ₽/г</li>
|
||||
<li class="text-sm text-gray-500">PETG — от 28 ₽/г</li>
|
||||
<li class="text-sm text-gray-500">ABS — от 25 ₽/г</li>
|
||||
<li class="text-sm text-gray-500">Нейлон — от 50 ₽/г</li>
|
||||
<li class="text-sm text-gray-500">Поликарбонат — от 60 ₽/г</li>
|
||||
<li class="text-sm text-gray-500">TPU — от 40 ₽/г</li>
|
||||
<li class="text-sm text-gray-500">PA-CF — от 75 ₽/г</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 border-t border-gray-100 pt-6 flex flex-col sm:flex-row items-center justify-between gap-3">
|
||||
<p class="text-xs text-gray-400">© {{ year }} Filam3D. 3D-печать на заказ.</p>
|
||||
<p class="text-xs text-gray-400">STL, 3MF, OBJ · FDM-технология · Bambu Lab</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const year = new Date().getFullYear()
|
||||
</script>
|
||||
696
frontend/src/data/articles.js
Normal file
696
frontend/src/data/articles.js
Normal file
@@ -0,0 +1,696 @@
|
||||
export const articles = [
|
||||
{
|
||||
slug: 'chto-takoe-fdm-pechat',
|
||||
title: 'Что такое FDM-печать и как она работает',
|
||||
description: 'Подробный разбор технологии FDM (Fused Deposition Modeling): принцип работы, преимущества, ограничения и области применения.',
|
||||
category: 'Технологии',
|
||||
date: '2026-03-10',
|
||||
readTime: 8,
|
||||
image: null,
|
||||
content: `
|
||||
## Что такое FDM-печать?
|
||||
|
||||
**FDM (Fused Deposition Modeling)** — самая распространённая технология 3D-печати. Принтер нагревает пластиковую нить (филамент) до температуры плавления и послойно наносит материал, формируя объект.
|
||||
|
||||
## Как работает FDM-принтер
|
||||
|
||||
1. **Подача филамента** — катушка с пластиковой нитью (обычно 1.75 мм) подаётся в экструдер
|
||||
2. **Нагрев и плавление** — хотэнд нагревает пластик до 190–300°C в зависимости от материала
|
||||
3. **Послойное нанесение** — расплавленный пластик выдавливается через сопло (0.2–0.8 мм) и укладывается слоями
|
||||
4. **Охлаждение** — каждый слой затвердевает перед нанесением следующего
|
||||
|
||||
## Ключевые параметры печати
|
||||
|
||||
### Высота слоя
|
||||
Определяет качество поверхности и скорость печати:
|
||||
- **0.08–0.12 мм** — высочайшее качество, медленная печать
|
||||
- **0.16–0.20 мм** — оптимальный баланс качества и скорости
|
||||
- **0.24–0.40 мм** — быстрая печать, грубая поверхность
|
||||
|
||||
### Процент заполнения (Infill)
|
||||
Внутренняя структура детали:
|
||||
- **10–20%** — лёгкие декоративные детали
|
||||
- **30–50%** — функциональные детали
|
||||
- **80–100%** — максимальная прочность
|
||||
|
||||
### Скорость печати
|
||||
Современные принтеры (Bambu Lab, Prusa) печатают на скоростях 100–500 мм/с, что значительно быстрее принтеров предыдущего поколения.
|
||||
|
||||
## Преимущества FDM
|
||||
|
||||
- **Доступность** — самый дешёвый метод 3D-печати
|
||||
- **Широкий выбор материалов** — от PLA до углеволоконных композитов
|
||||
- **Большие объёмы** — области печати до 500×500×500 мм
|
||||
- **Функциональные детали** — инженерные пластики выдерживают нагрузки
|
||||
|
||||
## Ограничения
|
||||
|
||||
- **Слоистость** — видна структура слоёв (можно убрать постобработкой)
|
||||
- **Точность** — ±0.1–0.3 мм (хуже SLA/SLS)
|
||||
- **Свесы** — углы больше 45° требуют поддержек
|
||||
- **Анизотропия** — деталь слабее по оси Z (между слоями)
|
||||
|
||||
## Для каких задач подходит FDM
|
||||
|
||||
- Прототипирование и макеты
|
||||
- Корпуса для электроники
|
||||
- Функциональные детали и запчасти
|
||||
- Оснастка и приспособления
|
||||
- Малые серии изделий (10–500 шт)
|
||||
`,
|
||||
},
|
||||
{
|
||||
slug: 'sravnenie-materialov-pla-petg-abs',
|
||||
title: 'PLA vs PETG vs ABS: какой пластик выбрать для 3D-печати',
|
||||
description: 'Детальное сравнение трёх самых популярных материалов для FDM-печати. Таблица свойств, плюсы и минусы, рекомендации.',
|
||||
category: 'Материалы',
|
||||
date: '2026-03-12',
|
||||
readTime: 10,
|
||||
image: null,
|
||||
content: `
|
||||
## Три кита FDM-печати
|
||||
|
||||
PLA, PETG и ABS — три самых популярных материала. Каждый из них имеет свои сильные стороны. Разберём подробно.
|
||||
|
||||
## PLA (полилактид)
|
||||
|
||||
**Температура печати:** 190–220°C
|
||||
**Стол:** 50–60°C
|
||||
**Термостойкость:** до 60°C
|
||||
|
||||
### Плюсы
|
||||
- Самый простой в печати — не капризничает
|
||||
- Отличная детализация и качество поверхности
|
||||
- Биоразлагаемый, производится из кукурузного крахмала
|
||||
- Минимальный варпинг (деформация)
|
||||
- Слабый запах при печати
|
||||
- Пищевой пластик (без красителей)
|
||||
|
||||
### Минусы
|
||||
- Низкая термостойкость — деформируется при 60°C
|
||||
- Хрупкий — ломается, а не гнётся
|
||||
- Слабая химическая стойкость
|
||||
- Разрушается на улице (UV + влага)
|
||||
|
||||
### Когда использовать
|
||||
Прототипы, макеты, декоративные изделия, выставочные образцы, POS-материалы.
|
||||
|
||||
---
|
||||
|
||||
## PETG (полиэтилентерефталатгликоль)
|
||||
|
||||
**Температура печати:** 230–250°C
|
||||
**Стол:** 70–80°C
|
||||
**Термостойкость:** до 80°C
|
||||
|
||||
### Плюсы
|
||||
- Прочный и ударостойкий — гнётся, а не ломается
|
||||
- Хорошая химическая стойкость
|
||||
- Морозостойкий (до -40°C)
|
||||
- Средняя UV-стойкость — живёт на улице
|
||||
- Пищевой пластик
|
||||
- Почти не даёт варпинга
|
||||
|
||||
### Минусы
|
||||
- Нитки (stringing) при печати — нужна настройка ретракта
|
||||
- Поверхность чуть хуже, чем PLA
|
||||
- Хуже детализация на мелких элементах
|
||||
|
||||
### Когда использовать
|
||||
Корпуса для электроники, уличные изделия, функциональные детали, контейнеры для пищевых продуктов.
|
||||
|
||||
---
|
||||
|
||||
## ABS (акрилонитрилбутадиенстирол)
|
||||
|
||||
**Температура печати:** 240–260°C
|
||||
**Стол:** 100–110°C
|
||||
**Термостойкость:** до 100°C
|
||||
|
||||
### Плюсы
|
||||
- Высокая термостойкость — до 100°C
|
||||
- Хорошая ударопрочность
|
||||
- Обрабатывается ацетоном (гладкая поверхность)
|
||||
- Дешёвый и широкодоступный
|
||||
- Классический материал — корпуса LEGO из ABS
|
||||
|
||||
### Минусы
|
||||
- Сильный варпинг — обязательна закрытая камера
|
||||
- Резкий запах при печати — нужна вытяжка
|
||||
- Не UV-стойкий — желтеет на солнце
|
||||
- Не пищевой пластик
|
||||
|
||||
### Когда использовать
|
||||
Автомобильные детали, корпуса приборов, изделия с постобработкой ацетоном, детали для высоких температур.
|
||||
|
||||
---
|
||||
|
||||
## Сравнительная таблица
|
||||
|
||||
| Параметр | PLA | PETG | ABS |
|
||||
|----------|-----|------|-----|
|
||||
| Простота печати | ★★★★★ | ★★★★ | ★★★ |
|
||||
| Прочность | ★★★ | ★★★★ | ★★★★ |
|
||||
| Термостойкость | ★★ | ★★★ | ★★★★ |
|
||||
| Гибкость | ★ | ★★★ | ★★ |
|
||||
| UV-стойкость | ★ | ★★★ | ★★ |
|
||||
| Химстойкость | ★ | ★★★ | ★★★ |
|
||||
| Цена | ★★★★★ | ★★★★ | ★★★★★ |
|
||||
| Запах | ★★★★★ | ★★★★ | ★★ |
|
||||
|
||||
## Наша рекомендация
|
||||
|
||||
- **Не знаете, что выбрать?** Начните с PETG — универсальный баланс всех свойств
|
||||
- **Прототип или декор?** PLA — идеальный выбор
|
||||
- **Нужна термостойкость?** ABS или нейлон
|
||||
- **Сложный случай?** Используйте наш [AI-подбор материала](/)
|
||||
`,
|
||||
},
|
||||
{
|
||||
slug: 'inzhenernye-plastiki-nylon-polikarbonat',
|
||||
title: 'Инженерные пластики: нейлон, поликарбонат, TPU — когда базовых материалов недостаточно',
|
||||
description: 'Обзор инженерных материалов для 3D-печати: PA (Nylon), PC (поликарбонат), TPU. Свойства, применение, особенности печати.',
|
||||
category: 'Материалы',
|
||||
date: '2026-03-15',
|
||||
readTime: 12,
|
||||
image: null,
|
||||
content: `
|
||||
## Когда PLA и PETG не хватает
|
||||
|
||||
Базовые материалы закрывают 70% задач. Но если деталь работает при высоких нагрузках, экстремальных температурах или требует гибкости — нужны инженерные пластики.
|
||||
|
||||
## PA (Nylon) — нейлон
|
||||
|
||||
**Температура печати:** 260–290°C
|
||||
**Стол:** 80–100°C
|
||||
**Термостойкость:** до 120°C
|
||||
**Цена:** ~50 ₽/г
|
||||
|
||||
### Свойства
|
||||
- **Износостойкость** — идеален для трущихся деталей
|
||||
- **Прочность** — один из самых прочных FDM-материалов
|
||||
- **Гибкость** — гнётся, а не ломается
|
||||
- **Химстойкость** — устойчив к маслам, бензину, щелочам
|
||||
|
||||
### Особенности печати
|
||||
- Гигроскопичен — впитывает влагу. **Обязательно** сушить перед печатью
|
||||
- Сильный варпинг — нужна закрытая камера с подогревом
|
||||
- Адгезия — клей-карандаш или PVA на стол
|
||||
|
||||
### Применение
|
||||
Шестерни, втулки, подшипники скольжения, крепёжные элементы, функциональные петли, автозапчасти.
|
||||
|
||||
---
|
||||
|
||||
## PC (поликарбонат)
|
||||
|
||||
**Температура печати:** 270–310°C
|
||||
**Стол:** 110–130°C
|
||||
**Термостойкость:** до 140°C
|
||||
**Цена:** ~60 ₽/г
|
||||
|
||||
### Свойства
|
||||
- **Максимальная термостойкость** среди FDM-пластиков
|
||||
- **Ударопрочность** — не разбивается при падении
|
||||
- **Оптическая прозрачность** (натуральный цвет)
|
||||
- **UV-стойкость** — не разрушается на солнце
|
||||
|
||||
### Особенности печати
|
||||
- Требует высоких температур — не каждый принтер справится
|
||||
- Обязательна закрытая камера с подогревом до 60°C+
|
||||
- Сильный варпинг — нужен хорошо прогретый стол
|
||||
|
||||
### Применение
|
||||
Корпуса для горячих сред (рядом с двигателем), защитные кожухи, электротехнические корпуса, светорассеиватели.
|
||||
|
||||
---
|
||||
|
||||
## TPU — термопластичный полиуретан
|
||||
|
||||
**Температура печати:** 220–250°C
|
||||
**Стол:** 40–60°C
|
||||
**Термостойкость:** до 80°C
|
||||
**Цена:** ~40 ₽/г
|
||||
**Твёрдость по Шору:** 85A–95A
|
||||
|
||||
### Свойства
|
||||
- **Эластичность** — растягивается и возвращает форму
|
||||
- **Ударопрочность** — поглощает вибрации
|
||||
- **Износостойкость** — не истирается
|
||||
- **Химстойкость** — масла, жиры, многие растворители
|
||||
|
||||
### Особенности печати
|
||||
- Медленная печать (15–30 мм/с) — мягкий материал застревает в экструдере
|
||||
- Директ-экструдер обязателен — боуден не справится
|
||||
- Ретракт минимальный или отключён
|
||||
|
||||
### Применение
|
||||
Прокладки, уплотнители, амортизаторы, защитные бамперы, гибкие петли, чехлы, вибродемпферы.
|
||||
|
||||
---
|
||||
|
||||
## PA-CF — нейлон с углеволокном
|
||||
|
||||
**Температура печати:** 270–300°C
|
||||
**Термостойкость:** до 150°C
|
||||
**Цена:** ~75 ₽/г
|
||||
|
||||
Это **композитный** материал — нейлон, армированный короткими углеволокнами.
|
||||
|
||||
### Свойства
|
||||
- Жёсткость как у алюминия
|
||||
- Минимальная термическая деформация
|
||||
- Лёгкий — плотность 1.18 г/см³
|
||||
- Отличная размерная стабильность
|
||||
|
||||
### Когда использовать
|
||||
Замена алюминиевых деталей, высоконагруженные кронштейны, оснастка для производства, детали дронов.
|
||||
|
||||
### Важно
|
||||
Углеволокно — абразив. Стандартные латунные сопла стираются за дни. Нужно **закалённое стальное** или **рубиновое** сопло.
|
||||
`,
|
||||
},
|
||||
{
|
||||
slug: 'kak-podgotovit-model-dlya-3d-pechati',
|
||||
title: 'Как подготовить 3D-модель для печати: форматы, ошибки, чеклист',
|
||||
description: 'Руководство по подготовке 3D-моделей к печати: STL vs 3MF vs OBJ, проверка водонепроницаемости, типичные ошибки и как их избежать.',
|
||||
category: 'Руководства',
|
||||
date: '2026-03-18',
|
||||
readTime: 7,
|
||||
image: null,
|
||||
content: `
|
||||
## Форматы файлов
|
||||
|
||||
### STL (Standard Triangle Language)
|
||||
Самый распространённый формат для 3D-печати. Описывает поверхность модели как набор треугольников.
|
||||
|
||||
- **Плюсы:** поддерживается всеми слайсерами и принтерами
|
||||
- **Минусы:** нет информации о цвете, материале, единицах измерения
|
||||
- **Бинарный vs ASCII:** бинарный файл в 5–10 раз меньше — используйте его
|
||||
|
||||
### 3MF (3D Manufacturing Format)
|
||||
Современный формат, разработанный специально для 3D-печати.
|
||||
|
||||
- **Плюсы:** хранит цвет, материал, текстуры, метаданные. Компактный (сжатый)
|
||||
- **Минусы:** не все старые программы поддерживают
|
||||
- **Рекомендация:** если ваш софт поддерживает — используйте 3MF
|
||||
|
||||
### OBJ (Wavefront)
|
||||
Формат из индустрии 3D-графики.
|
||||
|
||||
- **Плюсы:** поддерживает текстурные координаты и материалы
|
||||
- **Минусы:** файлы большие, нет метаданных печати
|
||||
|
||||
## Контроль качества модели
|
||||
|
||||
### Водонепроницаемость (Watertight)
|
||||
Модель для печати **должна** быть замкнутой — без дыр, щелей, инвертированных нормалей.
|
||||
|
||||
**Как проверить:**
|
||||
- В Blender: Select All → Mesh → Clean Up → Non-Manifold
|
||||
- В Meshmixer: Analysis → Inspector
|
||||
- В Windows 3D Builder: автоматически исправляет ошибки
|
||||
- В нашем калькуляторе — проверяется автоматически при загрузке
|
||||
|
||||
### Минимальная толщина стенок
|
||||
- **PLA/PETG/ABS:** минимум 1.2 мм (3 периметра при сопле 0.4 мм)
|
||||
- **Нейлон/TPU:** минимум 1.6 мм
|
||||
- **Тонкие стенки < 0.8 мм** — не напечатаются или будут хрупкими
|
||||
|
||||
### Размеры и масштаб
|
||||
- Убедитесь, что модель в **миллиметрах** (не в дюймах, метрах)
|
||||
- Проверьте габариты — должны соответствовать реальным размерам детали
|
||||
- Наш калькулятор показывает bounding box — сверьте с ожидаемым
|
||||
|
||||
## Типичные ошибки
|
||||
|
||||
### 1. Инвертированные нормали
|
||||
Нормали смотрят внутрь вместо наружу. Слайсер путает «внутри» и «снаружи».
|
||||
|
||||
**Решение:** пересчитать нормали (Blender: Shift+N)
|
||||
|
||||
### 2. Пересекающаяся геометрия
|
||||
Два тела проникают друг в друга без объединения.
|
||||
|
||||
**Решение:** Boolean Union перед экспортом
|
||||
|
||||
### 3. Висящие в воздухе элементы
|
||||
Части модели не соединены с основным телом.
|
||||
|
||||
**Решение:** проверить целостность, объединить или добавить поддержки
|
||||
|
||||
### 4. Слишком мелкие детали
|
||||
Элементы тоньше сопла (0.4 мм) не будут напечатаны.
|
||||
|
||||
**Решение:** увеличить мелкие элементы или использовать сопло 0.2 мм
|
||||
|
||||
## Чеклист перед загрузкой
|
||||
|
||||
- [ ] Модель водонепроницаемая (нет дыр)
|
||||
- [ ] Масштаб правильный (миллиметры)
|
||||
- [ ] Стенки не тоньше 1.2 мм
|
||||
- [ ] Нет пересекающейся геометрии
|
||||
- [ ] Формат: STL (бинарный), 3MF или OBJ
|
||||
- [ ] Размер файла до 50 МБ
|
||||
`,
|
||||
},
|
||||
{
|
||||
slug: '3d-pechat-korpusov-dlya-elektroniki',
|
||||
title: '3D-печать корпусов для электроники: материалы, допуски, проектирование',
|
||||
description: 'Практическое руководство по проектированию и печати корпусов для электронных устройств. Выбор материала, крепёж, вентиляция, IP-защита.',
|
||||
category: 'Применение',
|
||||
date: '2026-03-20',
|
||||
readTime: 11,
|
||||
image: null,
|
||||
content: `
|
||||
## Почему 3D-печать для корпусов
|
||||
|
||||
3D-печать — идеальный метод для корпусов электроники на этапе прототипирования и малых серий:
|
||||
|
||||
- **Итерации за часы**, а не недели (vs литьё под давлением)
|
||||
- **Нет затрат на пресс-форму** (от 300 000 ₽ для литья)
|
||||
- **Любая геометрия** — рёбра жёсткости, вентиляционные решётки, крепёж
|
||||
- **От 1 штуки** — экономически оправдано даже для единичного изделия
|
||||
|
||||
## Выбор материала
|
||||
|
||||
### Для помещений (комнатная температура)
|
||||
**PLA** — если корпус не несёт нагрузки и стоит на столе. Красиво, дёшево, быстро.
|
||||
|
||||
**PETG** — если нужна прочность. Не треснет при падении, устойчив к химии.
|
||||
|
||||
### Для улицы
|
||||
**PETG** — морозостойкий (-40°C), UV-стойкий, водостойкий. Оптимальный выбор.
|
||||
|
||||
**ASA** (аналог ABS) — лучшая UV-стойкость, но сложнее в печати.
|
||||
|
||||
### Для высоких температур (рядом с двигателем, в серверной)
|
||||
**ABS** — до 100°C. Классический выбор.
|
||||
|
||||
**PC (поликарбонат)** — до 140°C. Для экстремальных условий.
|
||||
|
||||
### Для вибраций
|
||||
**TPU** — гасит вибрации. Идеален для демпферов и прокладок.
|
||||
|
||||
## Проектирование корпуса
|
||||
|
||||
### Толщина стенок
|
||||
- **Минимум:** 2.0 мм (5 периметров)
|
||||
- **Оптимум:** 2.4–3.2 мм
|
||||
- **С рёбрами жёсткости:** стенки 2.0 мм + рёбра 1.6 мм
|
||||
|
||||
### Крепёж
|
||||
- **Саморезы:** отверстия диаметром на 0.5 мм меньше самореза
|
||||
- **Вставки с резьбой (heat-set inserts):** запаиваются паяльником. Диаметр отверстия = внешний диаметр вставки - 0.2 мм
|
||||
- **Защёлки (snap-fit):** работают отлично с PETG и нейлоном
|
||||
|
||||
### Вентиляция
|
||||
- Решётки с перемычками 0.8–1.2 мм и щелями 1.5–2.0 мм
|
||||
- Гексагональный паттерн — максимальная вентиляция при минимальной потере жёсткости
|
||||
|
||||
### Допуски
|
||||
- **Свободная посадка:** +0.3 мм на сторону
|
||||
- **Плотная посадка:** +0.1 мм на сторону
|
||||
- **Прессовая посадка:** -0.05 мм на сторону (для PLA)
|
||||
|
||||
### IP-защита
|
||||
Для достижения IP54 и выше:
|
||||
- Печать с 100% заполнением для стенок
|
||||
- Уплотнительная канавка для силиконовой прокладки
|
||||
- Ориентация печати — минимум горизонтальных швов
|
||||
|
||||
## Ориентация печати
|
||||
|
||||
Ориентация детали на столе влияет на:
|
||||
- **Прочность** — слои межслойной адгезии слабее
|
||||
- **Качество поверхности** — верх и низ гладкие, бока видна слоистость
|
||||
- **Поддержки** — минимизируйте их количество
|
||||
|
||||
**Рекомендация:** кладите корпус «открытой стороной вверх» — внутренняя поверхность менее критична.
|
||||
|
||||
## Стоимость
|
||||
|
||||
Типичный корпус 120×80×35 мм:
|
||||
- **PLA:** ~1 000–1 500 ₽
|
||||
- **PETG:** ~1 200–1 800 ₽
|
||||
- **ABS:** ~1 000–1 500 ₽
|
||||
- **Нейлон:** ~2 500–3 500 ₽
|
||||
|
||||
Загрузите вашу модель в наш [калькулятор](/) для точного расчёта.
|
||||
`,
|
||||
},
|
||||
{
|
||||
slug: 'postobrabotka-3d-pechatnyh-detalej',
|
||||
title: 'Постобработка 3D-печатных деталей: шлифовка, покраска, ацетон, резьба',
|
||||
description: 'Методы финишной обработки 3D-напечатанных деталей: механическая обработка, химическое сглаживание, покраска, нарезка резьбы.',
|
||||
category: 'Руководства',
|
||||
date: '2026-03-21',
|
||||
readTime: 9,
|
||||
image: null,
|
||||
content: `
|
||||
## Зачем нужна постобработка
|
||||
|
||||
FDM-печать оставляет видимую слоистость. Если деталь — функциональная запчасть внутри прибора, это не критично. Но если это корпус или визуальный прототип — постобработка обязательна.
|
||||
|
||||
## Шлифовка
|
||||
|
||||
Самый простой и универсальный метод.
|
||||
|
||||
### Процесс
|
||||
1. **Грубая шлифовка** — наждачная бумага P80–P120. Убираем слоистость
|
||||
2. **Средняя** — P240–P400. Выравниваем поверхность
|
||||
3. **Финишная** — P600–P1000. Подготовка под покраску
|
||||
|
||||
### Советы
|
||||
- Шлифуйте **с водой** — меньше пыли, лучше результат
|
||||
- PLA шлифуется легко, PETG — средне, нейлон — тяжело (мягкий, забивает бумагу)
|
||||
- Круглые формы удобнее обрабатывать на вращении (дрель + оправка)
|
||||
|
||||
### Стоимость в нашем сервисе: 300 ₽/деталь
|
||||
|
||||
---
|
||||
|
||||
## Ацетоновое сглаживание (только ABS)
|
||||
|
||||
Пары ацетона растворяют поверхность ABS, создавая идеально гладкую, глянцевую поверхность.
|
||||
|
||||
### Процесс
|
||||
1. Поместить деталь в герметичную ёмкость
|
||||
2. На дно — тряпка, смоченная ацетоном (не заливать!)
|
||||
3. Выдержать 15–60 минут (контролировать визуально)
|
||||
4. Извлечь и дать высохнуть 24 часа
|
||||
|
||||
### Результат
|
||||
- Полностью исчезает слоистость
|
||||
- Глянцевая поверхность
|
||||
- Повышается герметичность (поры затягиваются)
|
||||
|
||||
### Важно
|
||||
- Работает **только с ABS** (PLA и PETG не реагируют на ацетон)
|
||||
- Мелкие детали могут «поплыть» — не передерживайте
|
||||
- Работайте в вытяжном шкафу или на улице
|
||||
|
||||
### Стоимость: 400 ₽/деталь
|
||||
|
||||
---
|
||||
|
||||
## Покраска
|
||||
|
||||
### Подготовка
|
||||
1. Шлифовка до P400
|
||||
2. Грунтовка (2 слоя аэрозольного грунта)
|
||||
3. Промежуточная шлифовка P600
|
||||
4. Покраска
|
||||
|
||||
### Типы красок
|
||||
- **Аэрозольные баллоны** — быстро, равномерно, доступно
|
||||
- **Аэрограф** — точность, градиенты, металлики
|
||||
- **Кисть** — для мелких деталей и подкраски
|
||||
|
||||
### Советы
|
||||
- PLA и PETG хорошо держат краску после грунтовки
|
||||
- ABS после ацетоновой обработки красится идеально
|
||||
- Для защиты — финишный лак (матовый или глянцевый)
|
||||
|
||||
### Стоимость: 500 ₽/деталь
|
||||
|
||||
---
|
||||
|
||||
## Нарезка резьбы
|
||||
|
||||
### Метод 1: Вставки с резьбой (Heat-Set Inserts)
|
||||
Латунные вставки запаиваются паяльником. Надёжная многоразовая резьба.
|
||||
- M2, M3, M4, M5 — стандартные размеры
|
||||
- Температура: 200–220°C
|
||||
- Результат: профессиональное крепление
|
||||
|
||||
### Метод 2: Метчик
|
||||
Нарезка метчиком прямо в пластике.
|
||||
- Работает с PETG, ABS, нейлоном
|
||||
- PLA — хрупкий, резьба слабая
|
||||
- Отверстие: внутренний диаметр резьбы
|
||||
|
||||
### Метод 3: Моделирование резьбы
|
||||
Резьба моделируется прямо в CAD-файле и печатается.
|
||||
- Работает для крупных шагов (M8+)
|
||||
- Мелкая резьба (M3, M4) не пропечатается на FDM
|
||||
|
||||
### Стоимость: 200 ₽/отверстие
|
||||
|
||||
---
|
||||
|
||||
## Что выбрать
|
||||
|
||||
| Задача | Метод | Материал |
|
||||
|--------|-------|----------|
|
||||
| Убрать слоистость | Шлифовка | Любой |
|
||||
| Идеальная гладкость | Ацетон | Только ABS |
|
||||
| Визуальный прототип | Шлифовка + покраска | PLA, ABS |
|
||||
| Резьбовое крепление | Heat-set вставки | PETG, ABS, PA |
|
||||
| Герметичность | Ацетон или эпоксидка | ABS / любой |
|
||||
`,
|
||||
},
|
||||
{
|
||||
slug: 'skolko-stoit-3d-pechat',
|
||||
title: 'Сколько стоит 3D-печать на заказ: из чего складывается цена',
|
||||
description: 'Разбор ценообразования 3D-печати: стоимость материала, время печати, постобработка. Как сэкономить без потери качества.',
|
||||
category: 'Руководства',
|
||||
date: '2026-03-22',
|
||||
readTime: 6,
|
||||
image: null,
|
||||
content: `
|
||||
## Из чего складывается стоимость
|
||||
|
||||
### 1. Материал (30–50% стоимости)
|
||||
Стоимость зависит от:
|
||||
- **Объёма детали** — чем больше модель, тем больше пластика
|
||||
- **Процента заполнения** — 100% заполнение стоит в 2–3 раза больше, чем 20%
|
||||
- **Типа материала** — PLA/ABS: 25 ₽/г, нейлон: 50 ₽/г, PA-CF: 75 ₽/г
|
||||
|
||||
### 2. Время печати (30–40% стоимости)
|
||||
- Амортизация принтера
|
||||
- Электроэнергия
|
||||
- Оператор
|
||||
|
||||
**Факторы:** объём модели, высота слоя, скорость печати, количество поддержек.
|
||||
|
||||
Типичная ставка: **200 ₽/час**.
|
||||
|
||||
### 3. Постобработка (0–30% стоимости)
|
||||
- Шлифовка: 300 ₽
|
||||
- Покраска: 500 ₽
|
||||
- Нарезка резьбы: 200 ₽/отверстие
|
||||
- Ацетоновая обработка: 400 ₽
|
||||
|
||||
## Как сэкономить
|
||||
|
||||
### Снизить заполнение
|
||||
Для нефункциональных деталей 15–20% заполнения достаточно. Это снижает и расход материала, и время печати.
|
||||
|
||||
### Увеличить высоту слоя
|
||||
- 0.20 мм — стандарт
|
||||
- 0.28 мм — на 30% быстрее, разница в качестве минимальна для непрезентационных деталей
|
||||
|
||||
### Заказать партию
|
||||
Скидки за количество:
|
||||
- 2–5 шт: **5%**
|
||||
- 6–20 шт: **10%**
|
||||
- 21–100 шт: **15%**
|
||||
- 101–500 шт: **20%**
|
||||
|
||||
### Выбрать бюджетный материал
|
||||
PLA и ABS — 25 ₽/г. Если нет требований по температуре или прочности — зачем платить больше?
|
||||
|
||||
### Оптимизировать модель
|
||||
- Уменьшить толщину стенок до разумного минимума (2–3 мм)
|
||||
- Добавить рёбра жёсткости вместо толстых стенок
|
||||
- Уменьшить количество поддержек (ориентация модели)
|
||||
|
||||
## Примеры цен
|
||||
|
||||
| Деталь | Размер | Материал | Цена |
|
||||
|--------|--------|----------|------|
|
||||
| Крышка для Arduino | 70×50×15 мм | PLA | 350–500 ₽ |
|
||||
| Корпус датчика | 120×80×35 мм | PETG | 1 200–1 800 ₽ |
|
||||
| Шестерня | 40×40×15 мм | PA (Nylon) | 800–1 200 ₽ |
|
||||
| Кронштейн | 100×60×40 мм | PA-CF | 2 500–3 500 ₽ |
|
||||
| Прокладка | 80×80×5 мм | TPU | 400–600 ₽ |
|
||||
|
||||
*Точная цена зависит от геометрии. [Загрузите модель](/) — расчёт за секунды.*
|
||||
`,
|
||||
},
|
||||
{
|
||||
slug: 'b2b-3d-pechat-dlya-biznesa',
|
||||
title: '3D-печать для бизнеса: прототипы, малые серии, оснастка',
|
||||
description: 'Как компании используют 3D-печать: быстрое прототипирование, мелкосерийное производство, изготовление оснастки. Кейсы и экономика.',
|
||||
category: 'Применение',
|
||||
date: '2026-03-08',
|
||||
readTime: 8,
|
||||
image: null,
|
||||
content: `
|
||||
## Зачем бизнесу 3D-печать
|
||||
|
||||
### Прототипирование
|
||||
**Было:** эскиз → чертёж → пресс-форма → литьё → 3 месяца и 500 000 ₽
|
||||
**Стало:** CAD-модель → 3D-печать → 1 день и 1 500 ₽
|
||||
|
||||
3D-печать позволяет за день получить физический прототип и проверить:
|
||||
- Эргономику и форму
|
||||
- Сборку с другими деталями
|
||||
- Теплоотвод и вентиляцию
|
||||
- Реакцию заказчика
|
||||
|
||||
### Мелкосерийное производство
|
||||
При тиражах **до 500 штук** 3D-печать часто дешевле литья:
|
||||
|
||||
| Тираж | Литьё (с формой) | 3D-печать |
|
||||
|-------|------------------|-----------|
|
||||
| 1 шт | 350 000 ₽ | 1 500 ₽ |
|
||||
| 10 шт | 355 000 ₽ | 13 500 ₽ |
|
||||
| 100 шт | 380 000 ₽ | 105 000 ₽ |
|
||||
| 500 шт | 430 000 ₽ | 450 000 ₽ |
|
||||
| 1000 шт | 480 000 ₽ | 900 000 ₽ |
|
||||
|
||||
*Пересечение — при 500–1000 шт. Для меньших тиражей 3D-печать всегда выгоднее.*
|
||||
|
||||
### Оснастка и приспособления
|
||||
- Кондукторы для сборки
|
||||
- Калибры и шаблоны
|
||||
- Захваты для роботов
|
||||
- Формы для литья силикона
|
||||
|
||||
## Кто заказывает
|
||||
|
||||
### Электроника
|
||||
Корпуса для IoT-устройств, датчиков, контроллеров. От прототипа до серии в 100 штук.
|
||||
|
||||
### Машиностроение
|
||||
Замена сломанных деталей, модернизация узлов, изготовление оснастки.
|
||||
|
||||
### Медицина
|
||||
Модели для планирования операций, индивидуальные приспособления, корпуса приборов.
|
||||
|
||||
### Робототехника
|
||||
Детали дронов, кронштейны камер, захваты манипуляторов.
|
||||
|
||||
## Наш процесс для B2B
|
||||
|
||||
1. **Загрузите модель** в калькулятор — получите цену мгновенно
|
||||
2. **AI подберёт материал** под вашу задачу
|
||||
3. **Оформите заказ** — мы свяжемся для уточнения
|
||||
4. **Печать и доставка** — от 2 рабочих дней
|
||||
|
||||
Работаем по договору, предоставляем закрывающие документы.
|
||||
`,
|
||||
},
|
||||
]
|
||||
|
||||
export function getArticleBySlug(slug) {
|
||||
return articles.find((a) => a.slug === slug)
|
||||
}
|
||||
|
||||
export function getArticlesByCategory(category) {
|
||||
return articles.filter((a) => a.category === category)
|
||||
}
|
||||
|
||||
export const categories = [...new Set(articles.map((a) => a.category))]
|
||||
45
frontend/src/stores/admin.js
Normal file
45
frontend/src/stores/admin.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import api from '../api/client'
|
||||
|
||||
export const useAdminStore = defineStore('admin', () => {
|
||||
const token = ref(localStorage.getItem('admin_token') || '')
|
||||
const user = ref(null)
|
||||
const isAuthenticated = computed(() => !!token.value)
|
||||
|
||||
function setAuth(tokenValue, userData) {
|
||||
token.value = tokenValue
|
||||
user.value = userData
|
||||
localStorage.setItem('admin_token', tokenValue)
|
||||
api.defaults.headers.common['Authorization'] = `Bearer ${tokenValue}`
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = ''
|
||||
user.value = null
|
||||
localStorage.removeItem('admin_token')
|
||||
delete api.defaults.headers.common['Authorization']
|
||||
}
|
||||
|
||||
// Restore auth header on init
|
||||
if (token.value) {
|
||||
api.defaults.headers.common['Authorization'] = `Bearer ${token.value}`
|
||||
}
|
||||
|
||||
async function login(email, password) {
|
||||
const { data } = await api.post('/admin/login', { email, password })
|
||||
setAuth(data.token, { email: data.email, name: data.name })
|
||||
return data
|
||||
}
|
||||
|
||||
async function fetchMe() {
|
||||
try {
|
||||
const { data } = await api.get('/admin/me')
|
||||
user.value = data
|
||||
} catch {
|
||||
logout()
|
||||
}
|
||||
}
|
||||
|
||||
return { token, user, isAuthenticated, login, logout, fetchMe, setAuth }
|
||||
})
|
||||
131
frontend/src/views/ArticleView.vue
Normal file
131
frontend/src/views/ArticleView.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div v-if="article" class="mx-auto max-w-3xl">
|
||||
<router-link to="/blog" class="mb-6 inline-flex items-center text-sm text-gray-500 hover:text-gray-700">
|
||||
<svg class="mr-1 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
Все статьи
|
||||
</router-link>
|
||||
|
||||
<article>
|
||||
<header class="mb-8">
|
||||
<div class="mb-3 flex items-center gap-3">
|
||||
<span class="rounded-full bg-primary-100 px-3 py-1 text-xs font-medium text-primary-700">
|
||||
{{ article.category }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-400">{{ formatDate(article.date) }}</span>
|
||||
<span class="text-sm text-gray-400">{{ article.readTime }} мин чтения</span>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 leading-tight">{{ article.title }}</h1>
|
||||
<p class="mt-3 text-lg text-gray-500">{{ article.description }}</p>
|
||||
</header>
|
||||
|
||||
<div class="prose prose-gray max-w-none" v-html="renderedContent"></div>
|
||||
|
||||
<!-- CTA -->
|
||||
<div class="mt-12 rounded-xl bg-primary-50 border border-primary-100 p-6 text-center">
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-2">Нужна 3D-печать?</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">Загрузите модель и получите точный расчёт стоимости за секунды</p>
|
||||
<router-link to="/" class="btn-primary">Рассчитать стоимость</router-link>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Related articles -->
|
||||
<div v-if="relatedArticles.length" class="mt-12">
|
||||
<h3 class="mb-4 text-lg font-bold text-gray-900">Читайте также</h3>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<router-link
|
||||
v-for="related in relatedArticles"
|
||||
:key="related.slug"
|
||||
:to="`/blog/${related.slug}`"
|
||||
class="card group transition-shadow hover:shadow-md"
|
||||
>
|
||||
<span class="text-xs text-primary-600 font-medium">{{ related.category }}</span>
|
||||
<h4 class="mt-1 text-sm font-bold text-gray-900 group-hover:text-primary-600 transition-colors">
|
||||
{{ related.title }}
|
||||
</h4>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center py-20">
|
||||
<p class="text-gray-500">Статья не найдена</p>
|
||||
<router-link to="/blog" class="btn-primary mt-4 inline-block">Все статьи</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { getArticleBySlug, articles } from '../data/articles'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const article = computed(() => getArticleBySlug(route.params.slug))
|
||||
|
||||
const renderedContent = computed(() => {
|
||||
if (!article.value) return ''
|
||||
return simpleMarkdown(article.value.content)
|
||||
})
|
||||
|
||||
const relatedArticles = computed(() => {
|
||||
if (!article.value) return []
|
||||
return articles
|
||||
.filter((a) => a.slug !== article.value.slug)
|
||||
.filter((a) => a.category === article.value.category)
|
||||
.slice(0, 2)
|
||||
})
|
||||
|
||||
watch(() => route.params.slug, () => {
|
||||
window.scrollTo(0, 0)
|
||||
if (article.value) {
|
||||
document.title = `${article.value.title} — Filam3D`
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
function formatDate(dateStr) {
|
||||
return new Date(dateStr).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
}
|
||||
|
||||
function simpleMarkdown(md) {
|
||||
let html = md
|
||||
.replace(/^### (.+)$/gm, '<h3 class="text-lg font-bold text-gray-900 mt-6 mb-2">$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2 class="text-xl font-bold text-gray-900 mt-8 mb-3">$1</h2>')
|
||||
.replace(/^\- \[ \] (.+)$/gm, '<div class="flex items-center gap-2 my-1"><input type="checkbox" disabled class="rounded"><span class="text-sm text-gray-700">$1</span></div>')
|
||||
.replace(/^\- (.+)$/gm, '<li class="ml-4 text-gray-700">$1</li>')
|
||||
.replace(/^(\d+)\. (.+)$/gm, '<li class="ml-4 text-gray-700 list-decimal">$2</li>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/`(.+?)`/g, '<code class="rounded bg-gray-100 px-1.5 py-0.5 text-sm font-mono text-primary-700">$1</code>')
|
||||
.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2" class="text-primary-600 hover:text-primary-700 underline">$1</a>')
|
||||
.replace(/^---$/gm, '<hr class="my-6 border-gray-200">')
|
||||
.replace(/^\|(.+)$/gm, (match) => {
|
||||
const cells = match.split('|').filter(c => c.trim())
|
||||
if (cells.every(c => /^[\s-:]+$/.test(c))) return ''
|
||||
const isHeader = match.includes('---')
|
||||
const tag = isHeader ? 'th' : 'td'
|
||||
const cls = isHeader
|
||||
? 'px-3 py-2 text-left text-xs font-semibold text-gray-600 bg-gray-50'
|
||||
: 'px-3 py-2 text-sm text-gray-700 border-t border-gray-100'
|
||||
const row = cells.map(c => `<${tag} class="${cls}">${c.trim()}</${tag}>`).join('')
|
||||
return `<tr>${row}</tr>`
|
||||
})
|
||||
|
||||
// Wrap table rows
|
||||
html = html.replace(/((<tr>.*<\/tr>\s*)+)/g, '<div class="overflow-x-auto my-4"><table class="w-full border border-gray-200 rounded-lg overflow-hidden">$1</table></div>')
|
||||
|
||||
// Wrap list items
|
||||
html = html.replace(/((<li class="ml-4 text-gray-700">.*<\/li>\s*)+)/g, '<ul class="my-3 space-y-1">$1</ul>')
|
||||
|
||||
// Paragraphs for remaining text
|
||||
html = html.split('\n').map(line => {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) return ''
|
||||
if (trimmed.startsWith('<')) return line
|
||||
return `<p class="my-3 text-gray-700 leading-relaxed">${trimmed}</p>`
|
||||
}).join('\n')
|
||||
|
||||
return html
|
||||
}
|
||||
</script>
|
||||
71
frontend/src/views/BlogView.vue
Normal file
71
frontend/src/views/BlogView.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Блог о 3D-печати</h1>
|
||||
<p class="mt-2 text-base text-gray-500">Технологии, материалы, руководства и практические советы</p>
|
||||
</div>
|
||||
|
||||
<!-- Category filter -->
|
||||
<div class="mb-6 flex flex-wrap gap-2">
|
||||
<button
|
||||
@click="activeCategory = null"
|
||||
:class="[
|
||||
'rounded-full px-4 py-1.5 text-sm font-medium transition-colors',
|
||||
!activeCategory ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200',
|
||||
]"
|
||||
>
|
||||
Все
|
||||
</button>
|
||||
<button
|
||||
v-for="cat in categories"
|
||||
:key="cat"
|
||||
@click="activeCategory = cat"
|
||||
:class="[
|
||||
'rounded-full px-4 py-1.5 text-sm font-medium transition-colors',
|
||||
activeCategory === cat ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200',
|
||||
]"
|
||||
>
|
||||
{{ cat }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Articles grid -->
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<router-link
|
||||
v-for="article in filteredArticles"
|
||||
:key="article.slug"
|
||||
:to="`/blog/${article.slug}`"
|
||||
class="card group transition-shadow hover:shadow-md"
|
||||
>
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<span class="rounded-full bg-primary-100 px-2.5 py-0.5 text-xs font-medium text-primary-700">
|
||||
{{ article.category }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-400">{{ article.readTime }} мин</span>
|
||||
</div>
|
||||
<h2 class="mb-2 text-base font-bold text-gray-900 group-hover:text-primary-600 transition-colors leading-snug">
|
||||
{{ article.title }}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 leading-relaxed line-clamp-3">{{ article.description }}</p>
|
||||
<div class="mt-3 text-xs text-gray-400">{{ formatDate(article.date) }}</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { articles, categories } from '../data/articles'
|
||||
|
||||
const activeCategory = ref(null)
|
||||
|
||||
const filteredArticles = computed(() => {
|
||||
const sorted = [...articles].sort((a, b) => b.date.localeCompare(a.date))
|
||||
if (!activeCategory.value) return sorted
|
||||
return sorted.filter((a) => a.category === activeCategory.value)
|
||||
})
|
||||
|
||||
function formatDate(dateStr) {
|
||||
return new Date(dateStr).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
}
|
||||
</script>
|
||||
55
frontend/src/views/admin/AdminDashboard.vue
Normal file
55
frontend/src/views/admin/AdminDashboard.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="mb-6 text-2xl font-bold text-gray-900">Дашборд</h1>
|
||||
|
||||
<div v-if="loading" class="text-gray-500">Загрузка...</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<p class="text-sm text-gray-500">Заказов сегодня</p>
|
||||
<p class="mt-1 text-3xl font-bold text-gray-900">{{ stats.orders_today }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<p class="text-sm text-gray-500">Ожидают обработки</p>
|
||||
<p class="mt-1 text-3xl font-bold text-amber-600">{{ stats.pending_orders }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<p class="text-sm text-gray-500">Всего заказов</p>
|
||||
<p class="mt-1 text-3xl font-bold text-gray-900">{{ stats.total_orders }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<p class="text-sm text-gray-500">Выручка</p>
|
||||
<p class="mt-1 text-3xl font-bold text-green-600">{{ fmt(stats.total_revenue) }} ₽</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<p class="text-sm text-gray-500">Расчётов</p>
|
||||
<p class="mt-1 text-3xl font-bold text-gray-900">{{ stats.total_calculations }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<p class="text-sm text-gray-500">Материалов</p>
|
||||
<p class="mt-1 text-3xl font-bold text-gray-900">{{ stats.materials_count }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api from '../../api/client'
|
||||
|
||||
const loading = ref(true)
|
||||
const stats = ref({})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await api.get('/admin/dashboard')
|
||||
stats.value = data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function fmt(n) {
|
||||
return new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 0 }).format(n || 0)
|
||||
}
|
||||
</script>
|
||||
85
frontend/src/views/admin/AdminLayout.vue
Normal file
85
frontend/src/views/admin/AdminLayout.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div class="flex min-h-screen bg-gray-100">
|
||||
<!-- Sidebar -->
|
||||
<aside class="fixed inset-y-0 left-0 z-30 w-56 border-r border-gray-200 bg-white">
|
||||
<div class="flex h-14 items-center gap-2 border-b border-gray-200 px-4">
|
||||
<div class="flex h-7 w-7 items-center justify-center rounded-md bg-primary-600">
|
||||
<svg class="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-bold text-gray-900">Filam3D Admin</span>
|
||||
</div>
|
||||
|
||||
<nav class="p-3 space-y-0.5">
|
||||
<router-link v-for="item in nav" :key="item.to" :to="item.to"
|
||||
class="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900"
|
||||
active-class="!bg-primary-50 !text-primary-700"
|
||||
exact
|
||||
>
|
||||
<component :is="item.icon" class="h-4 w-4" />
|
||||
{{ item.label }}
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<div class="absolute bottom-0 left-0 right-0 border-t border-gray-200 p-3">
|
||||
<div class="mb-2 px-3 text-xs text-gray-400 truncate">{{ adminStore.user?.email }}</div>
|
||||
<button @click="handleLogout" class="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-900">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
|
||||
</svg>
|
||||
Выход
|
||||
</button>
|
||||
<router-link to="/" class="mt-1 flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-900">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
На сайт
|
||||
</router-link>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main -->
|
||||
<div class="ml-56 flex-1">
|
||||
<div class="p-6">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, h } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAdminStore } from '../../stores/admin'
|
||||
|
||||
const router = useRouter()
|
||||
const adminStore = useAdminStore()
|
||||
|
||||
onMounted(async () => {
|
||||
if (!adminStore.isAuthenticated) {
|
||||
router.push('/admin/login')
|
||||
return
|
||||
}
|
||||
await adminStore.fetchMe()
|
||||
})
|
||||
|
||||
function handleLogout() {
|
||||
adminStore.logout()
|
||||
router.push('/admin/login')
|
||||
}
|
||||
|
||||
const IconDashboard = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '2' }, [h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z' })])}
|
||||
const IconOrders = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '2' }, [h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z' })])}
|
||||
const IconMaterials = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '2' }, [h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9' })])}
|
||||
const IconSettings = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '2' }, [h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z' }), h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z' })])}
|
||||
const IconUsers = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '2' }, [h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z' })])}
|
||||
|
||||
const nav = [
|
||||
{ to: '/admin', label: 'Дашборд', icon: IconDashboard },
|
||||
{ to: '/admin/orders', label: 'Заказы (CRM)', icon: IconOrders },
|
||||
{ to: '/admin/materials', label: 'Материалы', icon: IconMaterials },
|
||||
{ to: '/admin/users', label: 'Администраторы', icon: IconUsers },
|
||||
{ to: '/admin/settings', label: 'Настройки', icon: IconSettings },
|
||||
]
|
||||
</script>
|
||||
59
frontend/src/views/admin/AdminLogin.vue
Normal file
59
frontend/src/views/admin/AdminLogin.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div class="flex min-h-[80vh] items-center justify-center">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="mb-8 text-center">
|
||||
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-primary-600">
|
||||
<svg class="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-xl font-bold text-gray-900">Админ-панель</h1>
|
||||
<p class="text-sm text-gray-500">Filam3D</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="card">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium text-gray-700">Email</label>
|
||||
<input v-model="email" type="email" required class="input-field" placeholder="admin@filam3d.ru" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium text-gray-700">Пароль</label>
|
||||
<input v-model="password" type="password" required class="input-field" />
|
||||
</div>
|
||||
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
|
||||
<button type="submit" :disabled="loading" class="btn-primary w-full">
|
||||
{{ loading ? 'Вход...' : 'Войти' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAdminStore } from '../../stores/admin'
|
||||
|
||||
const router = useRouter()
|
||||
const adminStore = useAdminStore()
|
||||
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function handleLogin() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await adminStore.login(email.value, password.value)
|
||||
router.push('/admin')
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.detail || 'Ошибка входа'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
304
frontend/src/views/admin/AdminMaterials.vue
Normal file
304
frontend/src/views/admin/AdminMaterials.vue
Normal file
@@ -0,0 +1,304 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Материалы</h1>
|
||||
<button @click="openCreate" class="btn-primary flex items-center gap-1.5 text-sm">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Добавить материал
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-gray-500">Загрузка...</div>
|
||||
|
||||
<div v-else-if="materials.length === 0" class="text-center py-12 text-gray-400">Материалов нет</div>
|
||||
|
||||
<div v-else class="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="border-b border-gray-200 bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600">Название</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600">Категория</th>
|
||||
<th class="px-4 py-3 text-right font-semibold text-gray-600">Цена/г</th>
|
||||
<th class="px-4 py-3 text-right font-semibold text-gray-600">Плотность</th>
|
||||
<th class="px-4 py-3 text-center font-semibold text-gray-600">Активен</th>
|
||||
<th class="px-4 py-3 text-right font-semibold text-gray-600">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<tr v-for="m in materials" :key="m.id" class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-4 py-3 font-medium text-gray-900">{{ m.name }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span :class="categoryClass(m.category)" class="rounded-full px-2.5 py-0.5 text-xs font-medium">
|
||||
{{ categoryLabel(m.category) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-gray-700">{{ m.price_per_gram }} ₽</td>
|
||||
<td class="px-4 py-3 text-right text-gray-700">{{ m.density_g_cm3 }} г/см³</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span v-if="m.is_active" class="text-green-600">✓</span>
|
||||
<span v-else class="text-gray-300">✕</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button @click="openEdit(m)" class="rounded-md p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-700" title="Редактировать">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="confirmDelete(m)" class="rounded-md p-1.5 text-gray-400 hover:bg-red-50 hover:text-red-600" title="Удалить">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30" @click.self="showModal = false">
|
||||
<div class="w-full max-w-xl rounded-xl bg-white shadow-2xl max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between border-b border-gray-200 px-5 py-4">
|
||||
<h2 class="text-lg font-bold">{{ editingMaterial ? 'Редактировать материал' : 'Новый материал' }}</h2>
|
||||
<button @click="showModal = false" class="rounded-md p-1 text-gray-400 hover:bg-gray-100">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="saveMaterial" class="p-5 space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase text-gray-500">Название</label>
|
||||
<input v-model="form.name" required class="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase text-gray-500">Категория</label>
|
||||
<select v-model="form.category" required class="input-field">
|
||||
<option value="basic">Базовый</option>
|
||||
<option value="engineering">Инженерный</option>
|
||||
<option value="composite">Композитный</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase text-gray-500">Цена за грамм (₽)</label>
|
||||
<input v-model.number="form.price_per_gram" type="number" step="0.1" min="0" required class="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase text-gray-500">Плотность (г/см³)</label>
|
||||
<input v-model.number="form.density_g_cm3" type="number" step="0.01" min="0" required class="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase text-gray-500">Скорость потока (мм³/с)</label>
|
||||
<input v-model.number="form.flow_rate_mm3_s" type="number" step="0.1" min="0" required class="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase text-gray-500">Макс. темп. (°C)</label>
|
||||
<input v-model.number="form.max_temp_c" type="number" class="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase text-gray-500">Мин. темп. (°C)</label>
|
||||
<input v-model.number="form.min_temp_c" type="number" class="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase text-gray-500">Прочность</label>
|
||||
<select v-model="form.strength" class="input-field">
|
||||
<option value="low">Низкая</option>
|
||||
<option value="medium">Средняя</option>
|
||||
<option value="high">Высокая</option>
|
||||
<option value="very_high">Очень высокая</option>
|
||||
<option value="extreme">Экстремальная</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase text-gray-500">Гибкость</label>
|
||||
<select v-model="form.flexibility" class="input-field">
|
||||
<option value="low">Низкая</option>
|
||||
<option value="medium">Средняя</option>
|
||||
<option value="high">Высокая</option>
|
||||
<option value="very_high">Очень высокая</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase text-gray-500">Хим. стойкость</label>
|
||||
<select v-model="form.chemical_resistance" class="input-field">
|
||||
<option value="low">Низкая</option>
|
||||
<option value="medium">Средняя</option>
|
||||
<option value="high">Высокая</option>
|
||||
<option value="very_high">Очень высокая</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase text-gray-500">UV стойкость</label>
|
||||
<select v-model="form.uv_resistance" class="input-field">
|
||||
<option value="low">Низкая</option>
|
||||
<option value="medium">Средняя</option>
|
||||
<option value="high">Высокая</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end gap-4">
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input v-model="form.food_safe" type="checkbox" class="rounded border-gray-300" />
|
||||
Пищевой контакт
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input v-model="form.is_active" type="checkbox" class="rounded border-gray-300" />
|
||||
Активен
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase text-gray-500">Описание</label>
|
||||
<textarea v-model="form.description" rows="2" class="input-field"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button type="button" @click="showModal = false" class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" :disabled="saving" class="btn-primary text-sm">
|
||||
{{ saving ? 'Сохранение...' : (editingMaterial ? 'Сохранить' : 'Создать') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Delete confirm -->
|
||||
<Teleport to="body">
|
||||
<div v-if="deletingMaterial" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30" @click.self="deletingMaterial = null">
|
||||
<div class="w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-2">Удалить материал?</h3>
|
||||
<p class="text-sm text-gray-600 mb-5">
|
||||
Материал <strong>{{ deletingMaterial.name }}</strong> будет удалён. Это действие нельзя отменить.
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button @click="deletingMaterial = null" class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">
|
||||
Отмена
|
||||
</button>
|
||||
<button @click="deleteMaterial" class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700">
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api from '../../api/client'
|
||||
|
||||
const materials = ref([])
|
||||
const loading = ref(true)
|
||||
const showModal = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingMaterial = ref(null)
|
||||
const deletingMaterial = ref(null)
|
||||
|
||||
const defaultForm = () => ({
|
||||
name: '',
|
||||
category: 'basic',
|
||||
price_per_gram: 25,
|
||||
density_g_cm3: 1.2,
|
||||
flow_rate_mm3_s: 12,
|
||||
max_temp_c: 60,
|
||||
min_temp_c: -20,
|
||||
strength: 'medium',
|
||||
flexibility: 'low',
|
||||
chemical_resistance: 'low',
|
||||
uv_resistance: 'low',
|
||||
food_safe: false,
|
||||
is_active: true,
|
||||
description: '',
|
||||
})
|
||||
|
||||
const form = ref(defaultForm())
|
||||
|
||||
onMounted(() => loadMaterials())
|
||||
|
||||
async function loadMaterials() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/admin/materials')
|
||||
materials.value = data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingMaterial.value = null
|
||||
form.value = defaultForm()
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function openEdit(m) {
|
||||
editingMaterial.value = m
|
||||
form.value = {
|
||||
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,
|
||||
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,
|
||||
is_active: m.is_active,
|
||||
description: m.description || '',
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function saveMaterial() {
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingMaterial.value) {
|
||||
await api.put(`/admin/materials/${editingMaterial.value.id}`, form.value)
|
||||
} else {
|
||||
await api.post('/admin/materials', form.value)
|
||||
}
|
||||
showModal.value = false
|
||||
await loadMaterials()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(m) {
|
||||
deletingMaterial.value = m
|
||||
}
|
||||
|
||||
async function deleteMaterial() {
|
||||
await api.delete(`/admin/materials/${deletingMaterial.value.id}`)
|
||||
deletingMaterial.value = null
|
||||
await loadMaterials()
|
||||
}
|
||||
|
||||
function categoryLabel(c) {
|
||||
const map = { basic: 'Базовый', engineering: 'Инженерный', composite: 'Композитный' }
|
||||
return map[c] || c
|
||||
}
|
||||
|
||||
function categoryClass(c) {
|
||||
const map = {
|
||||
basic: 'bg-blue-100 text-blue-700',
|
||||
engineering: 'bg-purple-100 text-purple-700',
|
||||
composite: 'bg-orange-100 text-orange-700',
|
||||
}
|
||||
return map[c] || 'bg-gray-100 text-gray-600'
|
||||
}
|
||||
</script>
|
||||
190
frontend/src/views/admin/AdminOrders.vue
Normal file
190
frontend/src/views/admin/AdminOrders.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Заказы (CRM)</h1>
|
||||
<div class="flex gap-2">
|
||||
<select v-model="statusFilter" @change="loadOrders" class="input-field !w-auto !py-1.5 text-sm">
|
||||
<option value="">Все статусы</option>
|
||||
<option v-for="s in statuses" :key="s.value" :value="s.value">{{ s.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-gray-500">Загрузка...</div>
|
||||
|
||||
<div v-else-if="orders.length === 0" class="text-center py-12 text-gray-400">Заказов пока нет</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="order in orders"
|
||||
:key="order.id"
|
||||
class="rounded-xl border border-gray-200 bg-white p-4 hover:shadow-sm transition-shadow cursor-pointer"
|
||||
@click="openOrder(order)"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<span class="text-sm font-bold text-gray-900">{{ order.order_id }}</span>
|
||||
<span :class="statusClass(order.status)" class="rounded-full px-2.5 py-0.5 text-xs font-medium">
|
||||
{{ statusLabel(order.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
{{ order.client_name }}
|
||||
<span v-if="order.client_company" class="text-gray-400"> · {{ order.client_company }}</span>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-400">
|
||||
{{ order.client_phone }}
|
||||
<span v-if="order.material_name"> · {{ order.material_name }}</span>
|
||||
<span v-if="order.quantity"> · {{ order.quantity }} шт</span>
|
||||
<span> · {{ formatDate(order.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-lg font-bold text-gray-900">{{ fmt(order.total_rub) }} ₽</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order detail modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="selectedOrder" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30" @click.self="selectedOrder = null">
|
||||
<div class="w-full max-w-lg rounded-xl bg-white shadow-2xl max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between border-b border-gray-200 px-5 py-4">
|
||||
<h2 class="text-lg font-bold">{{ selectedOrder.order?.order_id }}</h2>
|
||||
<button @click="selectedOrder = null" class="rounded-md p-1 text-gray-400 hover:bg-gray-100">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-5 space-y-4">
|
||||
<!-- Status change -->
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-semibold uppercase text-gray-500">Статус</label>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="s in statuses"
|
||||
:key="s.value"
|
||||
@click="changeStatus(selectedOrder.order.order_id, s.value)"
|
||||
:class="[
|
||||
'rounded-full px-3 py-1 text-xs font-medium transition-colors',
|
||||
selectedOrder.order.status === s.value
|
||||
? statusClass(s.value)
|
||||
: 'bg-gray-100 text-gray-500 hover:bg-gray-200',
|
||||
]"
|
||||
>
|
||||
{{ s.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Client info -->
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-semibold uppercase text-gray-500">Клиент</label>
|
||||
<div class="rounded-lg bg-gray-50 p-3 text-sm space-y-1">
|
||||
<p><span class="text-gray-500">Имя:</span> {{ selectedOrder.order.client_name }}</p>
|
||||
<p><span class="text-gray-500">Телефон:</span> {{ selectedOrder.order.client_phone }}</p>
|
||||
<p v-if="selectedOrder.order.client_email"><span class="text-gray-500">Email:</span> {{ selectedOrder.order.client_email }}</p>
|
||||
<p v-if="selectedOrder.order.client_company"><span class="text-gray-500">Компания:</span> {{ selectedOrder.order.client_company }}</p>
|
||||
<p><span class="text-gray-500">Доставка:</span> {{ selectedOrder.order.delivery_method === 'pickup' ? 'Самовывоз' : 'Доставка' }}</p>
|
||||
<p v-if="selectedOrder.order.comment"><span class="text-gray-500">Комментарий:</span> {{ selectedOrder.order.comment }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calculation info -->
|
||||
<div v-if="selectedOrder.calculation">
|
||||
<label class="mb-1.5 block text-xs font-semibold uppercase text-gray-500">Расчёт</label>
|
||||
<div class="rounded-lg bg-gray-50 p-3 text-sm space-y-1">
|
||||
<p><span class="text-gray-500">Файл:</span> {{ selectedOrder.calculation.file_name }}</p>
|
||||
<p><span class="text-gray-500">Материал:</span> {{ selectedOrder.calculation.material_name }}</p>
|
||||
<p><span class="text-gray-500">Объём:</span> {{ selectedOrder.calculation.volume_cm3 }} см³</p>
|
||||
<p><span class="text-gray-500">Заполнение:</span> {{ selectedOrder.calculation.infill_percent }}%</p>
|
||||
<p><span class="text-gray-500">Слой:</span> {{ selectedOrder.calculation.layer_height_mm }} мм</p>
|
||||
<p><span class="text-gray-500">Кол-во:</span> {{ selectedOrder.calculation.quantity }} шт</p>
|
||||
<p><span class="text-gray-500">Вес:</span> {{ selectedOrder.calculation.weight_grams }} г</p>
|
||||
<p><span class="text-gray-500">Время печати:</span> {{ selectedOrder.calculation.print_time_hours }} ч</p>
|
||||
<p class="font-bold"><span class="text-gray-500">Итого:</span> {{ fmt(selectedOrder.calculation.total_rub) }} ₽</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-400">
|
||||
Создан: {{ formatDate(selectedOrder.order.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api from '../../api/client'
|
||||
|
||||
const orders = ref([])
|
||||
const loading = ref(true)
|
||||
const statusFilter = ref('')
|
||||
const selectedOrder = ref(null)
|
||||
|
||||
const statuses = [
|
||||
{ value: 'pending', label: 'Новый' },
|
||||
{ value: 'confirmed', label: 'Подтверждён' },
|
||||
{ value: 'printing', label: 'Печатается' },
|
||||
{ value: 'ready', label: 'Готов' },
|
||||
{ value: 'delivered', label: 'Выдан' },
|
||||
{ value: 'cancelled', label: 'Отменён' },
|
||||
]
|
||||
|
||||
onMounted(() => loadOrders())
|
||||
|
||||
async function loadOrders() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = statusFilter.value ? { status: statusFilter.value } : {}
|
||||
const { data } = await api.get('/admin/orders', { params })
|
||||
orders.value = data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openOrder(order) {
|
||||
const { data } = await api.get(`/admin/orders/${order.order_id}`)
|
||||
selectedOrder.value = data
|
||||
}
|
||||
|
||||
async function changeStatus(orderId, newStatus) {
|
||||
await api.patch(`/admin/orders/${orderId}/status`, { status: newStatus })
|
||||
selectedOrder.value.order.status = newStatus
|
||||
const idx = orders.value.findIndex((o) => o.order_id === orderId)
|
||||
if (idx !== -1) orders.value[idx].status = newStatus
|
||||
}
|
||||
|
||||
function statusLabel(s) {
|
||||
return statuses.find((x) => x.value === s)?.label || s
|
||||
}
|
||||
|
||||
function statusClass(s) {
|
||||
const map = {
|
||||
pending: 'bg-amber-100 text-amber-700',
|
||||
confirmed: 'bg-blue-100 text-blue-700',
|
||||
printing: 'bg-purple-100 text-purple-700',
|
||||
ready: 'bg-green-100 text-green-700',
|
||||
delivered: 'bg-gray-100 text-gray-600',
|
||||
cancelled: 'bg-red-100 text-red-700',
|
||||
}
|
||||
return map[s] || 'bg-gray-100 text-gray-600'
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function fmt(n) {
|
||||
return new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 0 }).format(n || 0)
|
||||
}
|
||||
</script>
|
||||
161
frontend/src/views/admin/AdminSettings.vue
Normal file
161
frontend/src/views/admin/AdminSettings.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="mb-6 text-2xl font-bold text-gray-900">Настройки</h1>
|
||||
|
||||
<div v-if="loading" class="text-gray-500">Загрузка...</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<!-- Settings groups -->
|
||||
<div v-for="group in settingsGroups" :key="group.title" class="rounded-xl border border-gray-200 bg-white">
|
||||
<div class="border-b border-gray-200 px-5 py-3">
|
||||
<h2 class="text-sm font-bold uppercase text-gray-500">{{ group.title }}</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
<div v-for="item in group.items" :key="item.key" class="flex items-center justify-between px-5 py-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ item.label }}</p>
|
||||
<p class="text-xs text-gray-400">{{ item.key }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="settingsValues[item.key]"
|
||||
class="input-field !w-64 !py-1.5 text-sm text-right"
|
||||
:placeholder="item.placeholder || ''"
|
||||
@keydown.enter="saveKey(item.key)"
|
||||
/>
|
||||
<button
|
||||
@click="saveKey(item.key)"
|
||||
:disabled="savingKey === item.key"
|
||||
class="rounded-lg bg-primary-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{{ savingKey === item.key ? '...' : 'Сохранить' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom settings -->
|
||||
<div class="rounded-xl border border-gray-200 bg-white">
|
||||
<div class="border-b border-gray-200 px-5 py-3 flex items-center justify-between">
|
||||
<h2 class="text-sm font-bold uppercase text-gray-500">Все настройки</h2>
|
||||
<button @click="showAddModal = true" class="text-xs font-medium text-primary-600 hover:text-primary-700">
|
||||
+ Добавить
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="allSettings.length === 0" class="px-5 py-8 text-center text-sm text-gray-400">
|
||||
Настроек пока нет
|
||||
</div>
|
||||
<div v-else class="divide-y divide-gray-100">
|
||||
<div v-for="s in allSettings" :key="s.key" class="flex items-center justify-between px-5 py-3">
|
||||
<div class="flex-1 min-w-0 mr-4">
|
||||
<p class="text-sm font-mono text-gray-700 truncate">{{ s.key }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-900 max-w-xs truncate">{{ s.value }}</span>
|
||||
<span class="text-xs text-gray-400">{{ formatDate(s.updated_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add setting modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showAddModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30" @click.self="showAddModal = false">
|
||||
<div class="w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-4">Новая настройка</h3>
|
||||
<form @submit.prevent="addSetting" class="space-y-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase text-gray-500">Ключ</label>
|
||||
<input v-model="newKey" required class="input-field" placeholder="time_rate_per_hour" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase text-gray-500">Значение</label>
|
||||
<input v-model="newValue" required class="input-field" placeholder="200" />
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button type="button" @click="showAddModal = false" class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" class="btn-primary text-sm">Создать</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api from '../../api/client'
|
||||
|
||||
const loading = ref(true)
|
||||
const allSettings = ref([])
|
||||
const settingsValues = ref({})
|
||||
const savingKey = ref(null)
|
||||
const showAddModal = ref(false)
|
||||
const newKey = ref('')
|
||||
const newValue = ref('')
|
||||
|
||||
const settingsGroups = [
|
||||
{
|
||||
title: 'Расчёт стоимости',
|
||||
items: [
|
||||
{ key: 'time_rate_per_hour', label: 'Ставка за час печати (руб)', placeholder: '200' },
|
||||
{ key: 'sanding_cost', label: 'Стоимость шлифовки (руб/шт)', placeholder: '300' },
|
||||
{ key: 'painting_cost', label: 'Стоимость покраски (руб/шт)', placeholder: '500' },
|
||||
{ key: 'threading_cost', label: 'Стоимость резьбы (руб/шт)', placeholder: '200' },
|
||||
{ key: 'acetone_smoothing_cost', label: 'Ацетоновая обработка (руб/шт)', placeholder: '400' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Уведомления',
|
||||
items: [
|
||||
{ key: 'telegram_enabled', label: 'Telegram уведомления (true/false)', placeholder: 'true' },
|
||||
{ key: 'company_name', label: 'Название компании', placeholder: 'Filam3D' },
|
||||
{ key: 'company_phone', label: 'Телефон', placeholder: '+7 (999) 123-45-67' },
|
||||
{ key: 'company_email', label: 'Email', placeholder: 'info@filam3d.ru' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
onMounted(() => loadSettings())
|
||||
|
||||
async function loadSettings() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/admin/settings')
|
||||
allSettings.value = data
|
||||
const map = {}
|
||||
data.forEach((s) => { map[s.key] = s.value })
|
||||
settingsValues.value = map
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveKey(key) {
|
||||
savingKey.value = key
|
||||
try {
|
||||
await api.put(`/admin/settings/${key}`, { value: settingsValues.value[key] || '' })
|
||||
await loadSettings()
|
||||
} finally {
|
||||
savingKey.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function addSetting() {
|
||||
await api.put(`/admin/settings/${newKey.value}`, { value: newValue.value })
|
||||
showAddModal.value = false
|
||||
newKey.value = ''
|
||||
newValue.value = ''
|
||||
await loadSettings()
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
</script>
|
||||
304
frontend/src/views/admin/AdminUsers.vue
Normal file
304
frontend/src/views/admin/AdminUsers.vue
Normal file
@@ -0,0 +1,304 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Администраторы</h1>
|
||||
<button @click="openCreate" class="btn-primary flex items-center gap-1.5 text-sm">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zM4 19.235v-.11a6.375 6.375 0 0112.75 0v.109A12.318 12.318 0 0110.374 21c-2.331 0-4.512-.645-6.374-1.766z" />
|
||||
</svg>
|
||||
Добавить админа
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Change own password -->
|
||||
<div class="mb-6 rounded-xl border border-gray-200 bg-white p-5">
|
||||
<h2 class="mb-3 text-sm font-bold uppercase text-gray-500">Сменить свой пароль</h2>
|
||||
<form @submit.prevent="changeOwnPassword" class="flex items-end gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="mb-1 block text-xs text-gray-500">Текущий пароль</label>
|
||||
<input v-model="pwd.current" type="password" required class="input-field" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="mb-1 block text-xs text-gray-500">Новый пароль</label>
|
||||
<input v-model="pwd.new_password" type="password" required minlength="6" class="input-field" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="mb-1 block text-xs text-gray-500">Повтор нового пароля</label>
|
||||
<input v-model="pwd.confirm" type="password" required minlength="6" class="input-field" />
|
||||
</div>
|
||||
<button type="submit" :disabled="pwdSaving" class="btn-primary whitespace-nowrap text-sm">
|
||||
{{ pwdSaving ? '...' : 'Сменить' }}
|
||||
</button>
|
||||
</form>
|
||||
<p v-if="pwdError" class="mt-2 text-sm text-red-600">{{ pwdError }}</p>
|
||||
<p v-if="pwdSuccess" class="mt-2 text-sm text-green-600">{{ pwdSuccess }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Users list -->
|
||||
<div v-if="loading" class="text-gray-500">Загрузка...</div>
|
||||
|
||||
<div v-else class="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="border-b border-gray-200 bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600">ID</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600">Имя</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600">Email</th>
|
||||
<th class="px-4 py-3 text-center font-semibold text-gray-600">Активен</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600">Создан</th>
|
||||
<th class="px-4 py-3 text-right font-semibold text-gray-600">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<tr v-for="u in users" :key="u.id" class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-4 py-3 text-gray-400">{{ u.id }}</td>
|
||||
<td class="px-4 py-3 font-medium text-gray-900">{{ u.name }}</td>
|
||||
<td class="px-4 py-3 text-gray-700">{{ u.email }}</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span v-if="u.is_active" class="text-green-600">✓</span>
|
||||
<span v-else class="text-red-400">✕</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-500 text-xs">{{ formatDate(u.created_at) }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button @click="openEdit(u)" class="rounded-md p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-700" title="Редактировать">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="openResetPassword(u)" class="rounded-md p-1.5 text-gray-400 hover:bg-amber-50 hover:text-amber-600" title="Сбросить пароль">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="confirmDelete(u)" class="rounded-md p-1.5 text-gray-400 hover:bg-red-50 hover:text-red-600" title="Удалить">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30" @click.self="showModal = false">
|
||||
<div class="w-full max-w-md rounded-xl bg-white shadow-2xl">
|
||||
<div class="flex items-center justify-between border-b border-gray-200 px-5 py-4">
|
||||
<h2 class="text-lg font-bold">{{ editingUser ? 'Редактировать админа' : 'Новый администратор' }}</h2>
|
||||
<button @click="showModal = false" class="rounded-md p-1 text-gray-400 hover:bg-gray-100">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form @submit.prevent="saveUser" class="p-5 space-y-4">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase text-gray-500">Имя</label>
|
||||
<input v-model="form.name" required class="input-field" placeholder="Иван Иванов" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase text-gray-500">Email</label>
|
||||
<input v-model="form.email" type="email" required class="input-field" placeholder="admin@filam3d.ru" />
|
||||
</div>
|
||||
<div v-if="!editingUser">
|
||||
<label class="mb-1 block text-xs font-semibold uppercase text-gray-500">Пароль</label>
|
||||
<input v-model="form.password" type="password" required minlength="6" class="input-field" />
|
||||
</div>
|
||||
<div v-if="editingUser">
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input v-model="form.is_active" type="checkbox" class="rounded border-gray-300" />
|
||||
Активен
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="formError" class="text-sm text-red-600">{{ formError }}</p>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button type="button" @click="showModal = false" class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" :disabled="saving" class="btn-primary text-sm">
|
||||
{{ saving ? 'Сохранение...' : (editingUser ? 'Сохранить' : 'Создать') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Reset password modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="resetUser" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30" @click.self="resetUser = null">
|
||||
<div class="w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-1">Сбросить пароль</h3>
|
||||
<p class="text-sm text-gray-500 mb-4">{{ resetUser.name }} ({{ resetUser.email }})</p>
|
||||
<form @submit.prevent="doResetPassword" class="space-y-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase text-gray-500">Новый пароль</label>
|
||||
<input v-model="resetNewPassword" type="password" required minlength="6" class="input-field" />
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button type="button" @click="resetUser = null" class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" class="rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700">
|
||||
Сбросить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Delete confirm -->
|
||||
<Teleport to="body">
|
||||
<div v-if="deletingUser" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30" @click.self="deletingUser = null">
|
||||
<div class="w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-2">Удалить администратора?</h3>
|
||||
<p class="text-sm text-gray-600 mb-5">
|
||||
<strong>{{ deletingUser.name }}</strong> ({{ deletingUser.email }}) будет удалён.
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button @click="deletingUser = null" class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">
|
||||
Отмена
|
||||
</button>
|
||||
<button @click="doDelete" class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700">
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api from '../../api/client'
|
||||
|
||||
const users = ref([])
|
||||
const loading = ref(true)
|
||||
const showModal = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingUser = ref(null)
|
||||
const formError = ref('')
|
||||
const deletingUser = ref(null)
|
||||
const resetUser = ref(null)
|
||||
const resetNewPassword = ref('')
|
||||
|
||||
const form = ref({ name: '', email: '', password: '', is_active: true })
|
||||
|
||||
// Change own password
|
||||
const pwd = ref({ current: '', new_password: '', confirm: '' })
|
||||
const pwdSaving = ref(false)
|
||||
const pwdError = ref('')
|
||||
const pwdSuccess = ref('')
|
||||
|
||||
onMounted(() => loadUsers())
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/admin/users')
|
||||
users.value = data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingUser.value = null
|
||||
form.value = { name: '', email: '', password: '', is_active: true }
|
||||
formError.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function openEdit(u) {
|
||||
editingUser.value = u
|
||||
form.value = { name: u.name, email: u.email, password: '', is_active: u.is_active }
|
||||
formError.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function saveUser() {
|
||||
saving.value = true
|
||||
formError.value = ''
|
||||
try {
|
||||
if (editingUser.value) {
|
||||
await api.put(`/admin/users/${editingUser.value.id}`, {
|
||||
name: form.value.name,
|
||||
email: form.value.email,
|
||||
is_active: form.value.is_active,
|
||||
})
|
||||
} else {
|
||||
await api.post('/admin/users', {
|
||||
name: form.value.name,
|
||||
email: form.value.email,
|
||||
password: form.value.password,
|
||||
})
|
||||
}
|
||||
showModal.value = false
|
||||
await loadUsers()
|
||||
} catch (e) {
|
||||
formError.value = e.response?.data?.detail || 'Ошибка сохранения'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openResetPassword(u) {
|
||||
resetUser.value = u
|
||||
resetNewPassword.value = ''
|
||||
}
|
||||
|
||||
async function doResetPassword() {
|
||||
await api.post(`/admin/users/${resetUser.value.id}/reset-password`, {
|
||||
new_password: resetNewPassword.value,
|
||||
})
|
||||
resetUser.value = null
|
||||
}
|
||||
|
||||
function confirmDelete(u) {
|
||||
deletingUser.value = u
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
try {
|
||||
await api.delete(`/admin/users/${deletingUser.value.id}`)
|
||||
deletingUser.value = null
|
||||
await loadUsers()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.detail || 'Ошибка удаления')
|
||||
deletingUser.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function changeOwnPassword() {
|
||||
pwdError.value = ''
|
||||
pwdSuccess.value = ''
|
||||
if (pwd.value.new_password !== pwd.value.confirm) {
|
||||
pwdError.value = 'Пароли не совпадают'
|
||||
return
|
||||
}
|
||||
pwdSaving.value = true
|
||||
try {
|
||||
await api.post('/admin/change-password', {
|
||||
current_password: pwd.value.current,
|
||||
new_password: pwd.value.new_password,
|
||||
})
|
||||
pwdSuccess.value = 'Пароль успешно изменён'
|
||||
pwd.value = { current: '', new_password: '', confirm: '' }
|
||||
} catch (e) {
|
||||
pwdError.value = e.response?.data?.detail || 'Ошибка смены пароля'
|
||||
} finally {
|
||||
pwdSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user