diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py new file mode 100644 index 0000000..6a8bf1d --- /dev/null +++ b/backend/app/dependencies.py @@ -0,0 +1,36 @@ +import logging + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models.admin_user import AdminUser +from app.services.auth import decode_access_token + +logger = logging.getLogger("app.dependencies") + +security = HTTPBearer() + + +async def get_current_admin( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: AsyncSession = Depends(get_db), +) -> AdminUser: + """Dependency that validates JWT and returns the current admin user.""" + token = credentials.credentials + payload = decode_access_token(token) + if not payload: + logger.warning("Invalid or expired token") + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Невалидный или просроченный токен") + + user_id = int(payload["sub"]) + result = await db.execute(select(AdminUser).where(AdminUser.id == user_id, AdminUser.is_active == True)) + user = result.scalar_one_or_none() + if not user: + logger.warning("Admin user not found or inactive: id=%d", user_id) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Пользователь не найден") + + logger.debug("Authenticated admin: id=%d, email=%s", user.id, user.email) + return user diff --git a/backend/app/models/admin_user.py b/backend/app/models/admin_user.py new file mode 100644 index 0000000..ee6880b --- /dev/null +++ b/backend/app/models/admin_user.py @@ -0,0 +1,16 @@ +from sqlalchemy import Boolean, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column +from datetime import datetime + +from app.database import Base + + +class AdminUser(Base): + __tablename__ = "admin_users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + email: Mapped[str] = mapped_column(String(200), unique=True, nullable=False) + password_hash: Mapped[str] = mapped_column(String(200), nullable=False) + name: Mapped[str] = mapped_column(String(200), nullable=False, default="Admin") + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column(default=func.now()) diff --git a/backend/app/models/app_settings.py b/backend/app/models/app_settings.py new file mode 100644 index 0000000..8342a7f --- /dev/null +++ b/backend/app/models/app_settings.py @@ -0,0 +1,15 @@ +from sqlalchemy import Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column +from datetime import datetime + +from app.database import Base + + +class AppSettings(Base): + __tablename__ = "app_settings" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False) + value: Mapped[str] = mapped_column(Text, nullable=False, default="") + description: Mapped[str | None] = mapped_column(String(300)) + updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py new file mode 100644 index 0000000..4ca4235 --- /dev/null +++ b/backend/app/routers/admin.py @@ -0,0 +1,577 @@ +import logging +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select, func, desc +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.dependencies import get_current_admin +from app.models.admin_user import AdminUser +from app.models.material import Material +from app.models.calculation import Calculation +from app.models.order import Order +from app.models.app_settings import AppSettings +from app.services.auth import verify_password, create_access_token, hash_password + +logger = logging.getLogger("app.routers.admin") + +router = APIRouter() + + +# ─── Auth ─────────────────────────────────────────────── + +class LoginRequest(BaseModel): + email: str + password: str + + +class LoginResponse(BaseModel): + token: str + email: str + name: str + + +class AdminProfile(BaseModel): + id: int + email: str + name: str + + +@router.post("/login", response_model=LoginResponse) +async def admin_login(data: LoginRequest, db: AsyncSession = Depends(get_db)): + logger.info("Admin login attempt: %s", data.email) + result = await db.execute(select(AdminUser).where(AdminUser.email == data.email, AdminUser.is_active == True)) + user = result.scalar_one_or_none() + + if not user or not verify_password(data.password, user.password_hash): + logger.warning("Login failed for: %s", data.email) + raise HTTPException(401, "Неверный email или пароль") + + token = create_access_token(user.id, user.email) + logger.info("Login successful: %s (id=%d)", user.email, user.id) + return LoginResponse(token=token, email=user.email, name=user.name) + + +@router.get("/me", response_model=AdminProfile) +async def admin_me(admin: AdminUser = Depends(get_current_admin)): + return AdminProfile(id=admin.id, email=admin.email, name=admin.name) + + +class ChangePasswordRequest(BaseModel): + current_password: str + new_password: str + + +@router.post("/change-password") +async def change_password( + data: ChangePasswordRequest, + admin: AdminUser = Depends(get_current_admin), + db: AsyncSession = Depends(get_db), +): + if not verify_password(data.current_password, admin.password_hash): + raise HTTPException(400, "Неверный текущий пароль") + admin.password_hash = hash_password(data.new_password) + await db.commit() + logger.info("Password changed for admin: %s", admin.email) + return {"status": "ok"} + + +# ─── Admin Users Management ───────────────────────────── + +class AdminUserOut(BaseModel): + id: int + email: str + name: str + is_active: bool + created_at: str + model_config = {"from_attributes": True} + + +class AdminUserCreate(BaseModel): + email: str + name: str + password: str + + +class AdminUserUpdate(BaseModel): + name: str | None = None + email: str | None = None + is_active: bool | None = None + + +class AdminResetPassword(BaseModel): + new_password: str + + +@router.get("/users", response_model=list[AdminUserOut]) +async def list_admin_users( + admin: AdminUser = Depends(get_current_admin), + db: AsyncSession = Depends(get_db), +): + logger.info("Admin users list requested by %s", admin.email) + result = await db.execute(select(AdminUser).order_by(AdminUser.id)) + users = result.scalars().all() + return [ + AdminUserOut( + id=u.id, email=u.email, name=u.name, + is_active=u.is_active, + created_at=u.created_at.isoformat() if u.created_at else "", + ) + for u in users + ] + + +@router.post("/users", response_model=AdminUserOut) +async def create_admin_user( + data: AdminUserCreate, + admin: AdminUser = Depends(get_current_admin), + db: AsyncSession = Depends(get_db), +): + logger.info("Creating admin user: %s (by %s)", data.email, admin.email) + existing = await db.execute(select(AdminUser).where(AdminUser.email == data.email)) + if existing.scalar_one_or_none(): + raise HTTPException(400, "Пользователь с таким email уже существует") + + user = AdminUser( + email=data.email, + name=data.name, + password_hash=hash_password(data.password), + ) + db.add(user) + await db.commit() + await db.refresh(user) + logger.info("Admin user created: id=%d, email=%s", user.id, user.email) + return AdminUserOut( + id=user.id, email=user.email, name=user.name, + is_active=user.is_active, + created_at=user.created_at.isoformat() if user.created_at else "", + ) + + +@router.put("/users/{user_id}", response_model=AdminUserOut) +async def update_admin_user( + user_id: int, + data: AdminUserUpdate, + admin: AdminUser = Depends(get_current_admin), + db: AsyncSession = Depends(get_db), +): + logger.info("Updating admin user id=%d (by %s)", user_id, admin.email) + result = await db.execute(select(AdminUser).where(AdminUser.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(404, "Пользователь не найден") + + if data.name is not None: + user.name = data.name + if data.email is not None: + dup = await db.execute(select(AdminUser).where(AdminUser.email == data.email, AdminUser.id != user_id)) + if dup.scalar_one_or_none(): + raise HTTPException(400, "Email уже занят") + user.email = data.email + if data.is_active is not None: + user.is_active = data.is_active + + await db.commit() + await db.refresh(user) + logger.info("Admin user updated: id=%d", user.id) + return AdminUserOut( + id=user.id, email=user.email, name=user.name, + is_active=user.is_active, + created_at=user.created_at.isoformat() if user.created_at else "", + ) + + +@router.post("/users/{user_id}/reset-password") +async def reset_admin_password( + user_id: int, + data: AdminResetPassword, + admin: AdminUser = Depends(get_current_admin), + db: AsyncSession = Depends(get_db), +): + logger.info("Resetting password for user id=%d (by %s)", user_id, admin.email) + result = await db.execute(select(AdminUser).where(AdminUser.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(404, "Пользователь не найден") + + user.password_hash = hash_password(data.new_password) + await db.commit() + logger.info("Password reset for user id=%d, email=%s", user.id, user.email) + return {"status": "ok"} + + +@router.delete("/users/{user_id}") +async def delete_admin_user( + user_id: int, + admin: AdminUser = Depends(get_current_admin), + db: AsyncSession = Depends(get_db), +): + if admin.id == user_id: + raise HTTPException(400, "Нельзя удалить самого себя") + + result = await db.execute(select(AdminUser).where(AdminUser.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(404, "Пользователь не найден") + + logger.info("Deleting admin user id=%d, email=%s (by %s)", user.id, user.email, admin.email) + await db.delete(user) + await db.commit() + return {"status": "deleted"} + + +# ─── Dashboard ────────────────────────────────────────── + +class DashboardStats(BaseModel): + total_orders: int + pending_orders: int + total_revenue: float + total_calculations: int + materials_count: int + orders_today: int + + +@router.get("/dashboard", response_model=DashboardStats) +async def dashboard(admin: AdminUser = Depends(get_current_admin), db: AsyncSession = Depends(get_db)): + logger.info("Dashboard requested by %s", admin.email) + + total_orders = (await db.execute(select(func.count(Order.id)))).scalar() or 0 + pending_orders = (await db.execute( + select(func.count(Order.id)).where(Order.status == "pending") + )).scalar() or 0 + total_revenue = (await db.execute(select(func.sum(Order.total_rub)))).scalar() or 0.0 + total_calcs = (await db.execute(select(func.count(Calculation.id)))).scalar() or 0 + materials_count = (await db.execute(select(func.count(Material.id)))).scalar() or 0 + + today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + orders_today = (await db.execute( + select(func.count(Order.id)).where(Order.created_at >= today_start) + )).scalar() or 0 + + return DashboardStats( + total_orders=total_orders, + pending_orders=pending_orders, + total_revenue=round(total_revenue, 2), + total_calculations=total_calcs, + materials_count=materials_count, + orders_today=orders_today, + ) + + +# ─── Materials CRUD ───────────────────────────────────── + +class MaterialCreate(BaseModel): + name: str + category: str + density_g_cm3: float + price_per_gram: float + flow_rate_mm3_s: float + max_temp_c: int | None = None + min_temp_c: int | None = None + strength: str | None = None + flexibility: str | None = None + chemical_resistance: str | None = None + uv_resistance: str | None = None + food_safe: bool = False + description: str | None = None + color_options: list[str] = [] + is_active: bool = True + + +class MaterialUpdate(MaterialCreate): + pass + + +class MaterialOut(MaterialCreate): + id: int + model_config = {"from_attributes": True} + + +@router.get("/materials", response_model=list[MaterialOut]) +async def list_materials(admin: AdminUser = Depends(get_current_admin), db: AsyncSession = Depends(get_db)): + logger.info("Admin materials list requested") + result = await db.execute(select(Material).order_by(Material.id)) + materials = result.scalars().all() + return materials + + +@router.post("/materials", response_model=MaterialOut) +async def create_material( + data: MaterialCreate, + admin: AdminUser = Depends(get_current_admin), + db: AsyncSession = Depends(get_db), +): + logger.info("Creating material: %s (by %s)", data.name, admin.email) + mat = Material(**data.model_dump()) + db.add(mat) + await db.commit() + await db.refresh(mat) + logger.info("Material created: id=%d, name=%s", mat.id, mat.name) + return mat + + +@router.put("/materials/{material_id}", response_model=MaterialOut) +async def update_material( + material_id: int, + data: MaterialUpdate, + admin: AdminUser = Depends(get_current_admin), + db: AsyncSession = Depends(get_db), +): + logger.info("Updating material id=%d (by %s)", material_id, admin.email) + result = await db.execute(select(Material).where(Material.id == material_id)) + mat = result.scalar_one_or_none() + if not mat: + raise HTTPException(404, "Материал не найден") + + for key, value in data.model_dump().items(): + setattr(mat, key, value) + await db.commit() + await db.refresh(mat) + logger.info("Material updated: id=%d, name=%s", mat.id, mat.name) + return mat + + +@router.delete("/materials/{material_id}") +async def delete_material( + material_id: int, + admin: AdminUser = Depends(get_current_admin), + db: AsyncSession = Depends(get_db), +): + logger.info("Deleting material id=%d (by %s)", material_id, admin.email) + result = await db.execute(select(Material).where(Material.id == material_id)) + mat = result.scalar_one_or_none() + if not mat: + raise HTTPException(404, "Материал не найден") + await db.delete(mat) + await db.commit() + logger.info("Material deleted: id=%d", material_id) + return {"status": "deleted"} + + +# ─── Orders CRM ───────────────────────────────────────── + +class OrderOut(BaseModel): + id: int + order_id: str + client_name: str + client_phone: str + client_email: str | None + client_company: str | None + delivery_method: str + comment: str | None + status: str + total_rub: float + created_at: str + updated_at: str + # calculation info + file_name: str | None = None + material_name: str | None = None + quantity: int | None = None + + +class OrderStatusUpdate(BaseModel): + status: str # pending, confirmed, printing, ready, delivered, cancelled + + +class OrderNote(BaseModel): + note: str + + +@router.get("/orders", response_model=list[OrderOut]) +async def list_orders( + status: str | None = None, + admin: AdminUser = Depends(get_current_admin), + db: AsyncSession = Depends(get_db), +): + logger.info("Admin orders list requested (status_filter=%s)", status) + query = select(Order).order_by(desc(Order.created_at)) + if status: + query = query.where(Order.status == status) + + result = await db.execute(query) + orders = result.scalars().all() + + out = [] + for o in orders: + # Get calculation info + calc_result = await db.execute(select(Calculation).where(Calculation.id == o.calculation_id)) + calc = calc_result.scalar_one_or_none() + + material_name = None + if calc: + mat_result = await db.execute(select(Material).where(Material.id == calc.material_id)) + mat = mat_result.scalar_one_or_none() + material_name = mat.name if mat else None + + out.append(OrderOut( + id=o.id, + order_id=o.order_id, + client_name=o.client_name, + client_phone=o.client_phone, + client_email=o.client_email, + client_company=o.client_company, + delivery_method=o.delivery_method, + comment=o.comment, + status=o.status, + total_rub=o.total_rub, + created_at=o.created_at.isoformat() if o.created_at else "", + updated_at=o.updated_at.isoformat() if o.updated_at else "", + file_name=calc.file_name if calc else None, + material_name=material_name, + quantity=calc.quantity if calc else None, + )) + + logger.info("Returning %d orders", len(out)) + return out + + +@router.get("/orders/{order_id}") +async def get_order( + order_id: str, + admin: AdminUser = Depends(get_current_admin), + db: AsyncSession = Depends(get_db), +): + logger.info("Admin order detail: %s", order_id) + result = await db.execute(select(Order).where(Order.order_id == order_id)) + order = result.scalar_one_or_none() + if not order: + raise HTTPException(404, "Заказ не найден") + + calc_result = await db.execute(select(Calculation).where(Calculation.id == order.calculation_id)) + calc = calc_result.scalar_one_or_none() + + material_name = None + calc_data = None + if calc: + mat_result = await db.execute(select(Material).where(Material.id == calc.material_id)) + mat = mat_result.scalar_one_or_none() + material_name = mat.name if mat else None + calc_data = { + "file_name": calc.file_name, + "file_format": calc.file_format, + "volume_cm3": calc.volume_cm3, + "bounding_box": calc.bounding_box, + "is_watertight": calc.is_watertight, + "material_id": calc.material_id, + "material_name": material_name, + "infill_percent": calc.infill_percent, + "layer_height_mm": calc.layer_height_mm, + "quantity": calc.quantity, + "post_processing": calc.post_processing, + "weight_grams": calc.weight_grams, + "material_cost_rub": calc.material_cost_rub, + "time_cost_rub": calc.time_cost_rub, + "print_time_hours": calc.print_time_hours, + "post_processing_cost_rub": calc.post_processing_cost_rub, + "total_rub": calc.total_rub, + "estimated_days": calc.estimated_days, + } + + return { + "order": { + "id": order.id, + "order_id": order.order_id, + "client_name": order.client_name, + "client_phone": order.client_phone, + "client_email": order.client_email, + "client_company": order.client_company, + "delivery_method": order.delivery_method, + "comment": order.comment, + "status": order.status, + "total_rub": order.total_rub, + "created_at": order.created_at.isoformat() if order.created_at else "", + "updated_at": order.updated_at.isoformat() if order.updated_at else "", + }, + "calculation": calc_data, + } + + +@router.patch("/orders/{order_id}/status") +async def update_order_status( + order_id: str, + data: OrderStatusUpdate, + admin: AdminUser = Depends(get_current_admin), + db: AsyncSession = Depends(get_db), +): + valid_statuses = ["pending", "confirmed", "printing", "ready", "delivered", "cancelled"] + if data.status not in valid_statuses: + raise HTTPException(400, f"Невалидный статус. Допустимые: {', '.join(valid_statuses)}") + + logger.info("Updating order %s status to '%s' (by %s)", order_id, data.status, admin.email) + result = await db.execute(select(Order).where(Order.order_id == order_id)) + order = result.scalar_one_or_none() + if not order: + raise HTTPException(404, "Заказ не найден") + + old_status = order.status + order.status = data.status + await db.commit() + logger.info("Order %s status changed: %s -> %s", order_id, old_status, data.status) + return {"order_id": order_id, "old_status": old_status, "new_status": data.status} + + +# ─── Settings ─────────────────────────────────────────── + +class SettingOut(BaseModel): + id: int + key: str + value: str + description: str | None + + +class SettingUpdate(BaseModel): + value: str + + +DEFAULT_SETTINGS = [ + {"key": "time_rate_per_hour", "value": "200", "description": "Стоимость часа печати (руб)"}, + {"key": "sanding_cost", "value": "300", "description": "Стоимость шлифовки (руб/шт)"}, + {"key": "painting_cost", "value": "500", "description": "Стоимость покраски (руб/шт)"}, + {"key": "threading_cost", "value": "200", "description": "Стоимость нарезки резьбы (руб/шт)"}, + {"key": "acetone_cost", "value": "400", "description": "Стоимость ацетоновой обработки (руб/шт)"}, + {"key": "company_name", "value": "Filam3D", "description": "Название компании"}, + {"key": "company_phone", "value": "", "description": "Телефон компании"}, + {"key": "company_email", "value": "", "description": "Email компании"}, +] + + +@router.get("/settings", response_model=list[SettingOut]) +async def list_settings( + admin: AdminUser = Depends(get_current_admin), + db: AsyncSession = Depends(get_db), +): + logger.info("Admin settings list requested") + result = await db.execute(select(AppSettings).order_by(AppSettings.id)) + settings_list = result.scalars().all() + + # Seed defaults if empty + if not settings_list: + logger.info("No settings found, seeding defaults...") + for s in DEFAULT_SETTINGS: + db.add(AppSettings(**s)) + await db.commit() + result = await db.execute(select(AppSettings).order_by(AppSettings.id)) + settings_list = result.scalars().all() + + return settings_list + + +@router.put("/settings/{key}") +async def update_setting( + key: str, + data: SettingUpdate, + admin: AdminUser = Depends(get_current_admin), + db: AsyncSession = Depends(get_db), +): + logger.info("Updating setting '%s' to '%s' (by %s)", key, data.value, admin.email) + result = await db.execute(select(AppSettings).where(AppSettings.key == key)) + setting = result.scalar_one_or_none() + if not setting: + raise HTTPException(404, "Настройка не найдена") + + setting.value = data.value + await db.commit() + logger.info("Setting updated: %s = %s", key, data.value) + return {"key": key, "value": data.value} diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py new file mode 100644 index 0000000..cc694e7 --- /dev/null +++ b/backend/app/services/auth.py @@ -0,0 +1,41 @@ +import logging +from datetime import datetime, timedelta, timezone + +import bcrypt +import jwt + +from app.config import settings + +logger = logging.getLogger("app.services.auth") + + +def hash_password(password: str) -> str: + return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() + + +def verify_password(plain: str, hashed: str) -> bool: + return bcrypt.checkpw(plain.encode(), hashed.encode()) + + +def create_access_token(user_id: int, email: str) -> str: + expire = datetime.now(timezone.utc) + timedelta(hours=settings.JWT_EXPIRE_HOURS) + payload = { + "sub": str(user_id), + "email": email, + "exp": expire, + } + token = jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM) + logger.info("Created JWT for user_id=%d, email=%s, expires=%s", user_id, email, expire) + return token + + +def decode_access_token(token: str) -> dict | None: + try: + payload = jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM]) + return payload + except jwt.ExpiredSignatureError: + logger.warning("JWT expired") + return None + except jwt.InvalidTokenError as e: + logger.warning("Invalid JWT: %s", e) + return None diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt new file mode 100644 index 0000000..ddfa36e --- /dev/null +++ b/frontend/public/robots.txt @@ -0,0 +1,6 @@ +User-agent: * +Allow: / +Sitemap: https://filam3d.ru/sitemap.xml + +# Не индексировать страницы заказов +Disallow: /order/ diff --git a/frontend/public/sitemap.xml b/frontend/public/sitemap.xml new file mode 100644 index 0000000..8681d85 --- /dev/null +++ b/frontend/public/sitemap.xml @@ -0,0 +1,58 @@ + + + + https://filam3d.ru/ + weekly + 1.0 + + + https://filam3d.ru/materials + monthly + 0.8 + + + https://filam3d.ru/blog + weekly + 0.9 + + + https://filam3d.ru/blog/chto-takoe-fdm-pechat + monthly + 0.7 + + + https://filam3d.ru/blog/sravnenie-materialov-pla-petg-abs + monthly + 0.7 + + + https://filam3d.ru/blog/inzhenernye-plastiki-nylon-polikarbonat + monthly + 0.7 + + + https://filam3d.ru/blog/kak-podgotovit-model-dlya-3d-pechati + monthly + 0.7 + + + https://filam3d.ru/blog/3d-pechat-korpusov-dlya-elektroniki + monthly + 0.7 + + + https://filam3d.ru/blog/postobrabotka-3d-pechatnyh-detalej + monthly + 0.7 + + + https://filam3d.ru/blog/skolko-stoit-3d-pechat + monthly + 0.7 + + + https://filam3d.ru/blog/b2b-3d-pechat-dlya-biznesa + monthly + 0.7 + + diff --git a/frontend/src/components/HeroSection.vue b/frontend/src/components/HeroSection.vue new file mode 100644 index 0000000..fb3d3c8 --- /dev/null +++ b/frontend/src/components/HeroSection.vue @@ -0,0 +1,33 @@ + diff --git a/frontend/src/components/SiteFooter.vue b/frontend/src/components/SiteFooter.vue new file mode 100644 index 0000000..5e7873b --- /dev/null +++ b/frontend/src/components/SiteFooter.vue @@ -0,0 +1,66 @@ + + + diff --git a/frontend/src/data/articles.js b/frontend/src/data/articles.js new file mode 100644 index 0000000..284e98e --- /dev/null +++ b/frontend/src/data/articles.js @@ -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))] diff --git a/frontend/src/stores/admin.js b/frontend/src/stores/admin.js new file mode 100644 index 0000000..e342ca4 --- /dev/null +++ b/frontend/src/stores/admin.js @@ -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 } +}) diff --git a/frontend/src/views/ArticleView.vue b/frontend/src/views/ArticleView.vue new file mode 100644 index 0000000..2da3277 --- /dev/null +++ b/frontend/src/views/ArticleView.vue @@ -0,0 +1,131 @@ + + + diff --git a/frontend/src/views/BlogView.vue b/frontend/src/views/BlogView.vue new file mode 100644 index 0000000..57d6c2e --- /dev/null +++ b/frontend/src/views/BlogView.vue @@ -0,0 +1,71 @@ + + + diff --git a/frontend/src/views/admin/AdminDashboard.vue b/frontend/src/views/admin/AdminDashboard.vue new file mode 100644 index 0000000..e8b0d7f --- /dev/null +++ b/frontend/src/views/admin/AdminDashboard.vue @@ -0,0 +1,55 @@ + + + diff --git a/frontend/src/views/admin/AdminLayout.vue b/frontend/src/views/admin/AdminLayout.vue new file mode 100644 index 0000000..19d595e --- /dev/null +++ b/frontend/src/views/admin/AdminLayout.vue @@ -0,0 +1,85 @@ + + + diff --git a/frontend/src/views/admin/AdminLogin.vue b/frontend/src/views/admin/AdminLogin.vue new file mode 100644 index 0000000..378359a --- /dev/null +++ b/frontend/src/views/admin/AdminLogin.vue @@ -0,0 +1,59 @@ + + + diff --git a/frontend/src/views/admin/AdminMaterials.vue b/frontend/src/views/admin/AdminMaterials.vue new file mode 100644 index 0000000..1c5a57f --- /dev/null +++ b/frontend/src/views/admin/AdminMaterials.vue @@ -0,0 +1,304 @@ + + + diff --git a/frontend/src/views/admin/AdminOrders.vue b/frontend/src/views/admin/AdminOrders.vue new file mode 100644 index 0000000..df6b404 --- /dev/null +++ b/frontend/src/views/admin/AdminOrders.vue @@ -0,0 +1,190 @@ + + + diff --git a/frontend/src/views/admin/AdminSettings.vue b/frontend/src/views/admin/AdminSettings.vue new file mode 100644 index 0000000..58196fb --- /dev/null +++ b/frontend/src/views/admin/AdminSettings.vue @@ -0,0 +1,161 @@ + + + diff --git a/frontend/src/views/admin/AdminUsers.vue b/frontend/src/views/admin/AdminUsers.vue new file mode 100644 index 0000000..8f2c2d3 --- /dev/null +++ b/frontend/src/views/admin/AdminUsers.vue @@ -0,0 +1,304 @@ + + +