This commit is contained in:
xds
2026-03-22 13:26:18 +03:00
parent 28a5d51389
commit f98c57a433
20 changed files with 2949 additions and 0 deletions

View File

@@ -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

View File

@@ -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())

View File

@@ -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())

View File

@@ -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}

View File

@@ -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

View File

@@ -0,0 +1,6 @@
User-agent: *
Allow: /
Sitemap: https://filam3d.ru/sitemap.xml
# Не индексировать страницы заказов
Disallow: /order/

View 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>

View 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>

View 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">&copy; {{ year }} Filam3D. 3D-печать на заказ.</p>
<p class="text-xs text-gray-400">STL, 3MF, OBJ &middot; FDM-технология &middot; Bambu Lab</p>
</div>
</div>
</footer>
</template>
<script setup>
const year = new Date().getFullYear()
</script>

View 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. **Нагрев и плавление** — хотэнд нагревает пластик до 190300°C в зависимости от материала
3. **Послойное нанесение** — расплавленный пластик выдавливается через сопло (0.20.8 мм) и укладывается слоями
4. **Охлаждение** — каждый слой затвердевает перед нанесением следующего
## Ключевые параметры печати
### Высота слоя
Определяет качество поверхности и скорость печати:
- **0.080.12 мм** — высочайшее качество, медленная печать
- **0.160.20 мм** — оптимальный баланс качества и скорости
- **0.240.40 мм** — быстрая печать, грубая поверхность
### Процент заполнения (Infill)
Внутренняя структура детали:
- **1020%** — лёгкие декоративные детали
- **3050%** — функциональные детали
- **80100%** — максимальная прочность
### Скорость печати
Современные принтеры (Bambu Lab, Prusa) печатают на скоростях 100500 мм/с, что значительно быстрее принтеров предыдущего поколения.
## Преимущества FDM
- **Доступность** — самый дешёвый метод 3D-печати
- **Широкий выбор материалов** — от PLA до углеволоконных композитов
- **Большие объёмы** — области печати до 500×500×500 мм
- **Функциональные детали** — инженерные пластики выдерживают нагрузки
## Ограничения
- **Слоистость** — видна структура слоёв (можно убрать постобработкой)
- **Точность** — ±0.10.3 мм (хуже SLA/SLS)
- **Свесы** — углы больше 45° требуют поддержек
- **Анизотропия** — деталь слабее по оси Z (между слоями)
## Для каких задач подходит FDM
- Прототипирование и макеты
- Корпуса для электроники
- Функциональные детали и запчасти
- Оснастка и приспособления
- Малые серии изделий (10500 шт)
`,
},
{
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 (полилактид)
**Температура печати:** 190220°C
**Стол:** 5060°C
**Термостойкость:** до 60°C
### Плюсы
- Самый простой в печати — не капризничает
- Отличная детализация и качество поверхности
- Биоразлагаемый, производится из кукурузного крахмала
- Минимальный варпинг (деформация)
- Слабый запах при печати
- Пищевой пластик (без красителей)
### Минусы
- Низкая термостойкость — деформируется при 60°C
- Хрупкий — ломается, а не гнётся
- Слабая химическая стойкость
- Разрушается на улице (UV + влага)
### Когда использовать
Прототипы, макеты, декоративные изделия, выставочные образцы, POS-материалы.
---
## PETG (полиэтилентерефталатгликоль)
**Температура печати:** 230250°C
**Стол:** 7080°C
**Термостойкость:** до 80°C
### Плюсы
- Прочный и ударостойкий — гнётся, а не ломается
- Хорошая химическая стойкость
- Морозостойкий (до -40°C)
- Средняя UV-стойкость — живёт на улице
- Пищевой пластик
- Почти не даёт варпинга
### Минусы
- Нитки (stringing) при печати — нужна настройка ретракта
- Поверхность чуть хуже, чем PLA
- Хуже детализация на мелких элементах
### Когда использовать
Корпуса для электроники, уличные изделия, функциональные детали, контейнеры для пищевых продуктов.
---
## ABS (акрилонитрилбутадиенстирол)
**Температура печати:** 240260°C
**Стол:** 100110°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) — нейлон
**Температура печати:** 260290°C
**Стол:** 80100°C
**Термостойкость:** до 120°C
**Цена:** ~50 ₽/г
### Свойства
- **Износостойкость** — идеален для трущихся деталей
- **Прочность** — один из самых прочных FDM-материалов
- **Гибкость** — гнётся, а не ломается
- **Химстойкость** — устойчив к маслам, бензину, щелочам
### Особенности печати
- Гигроскопичен — впитывает влагу. **Обязательно** сушить перед печатью
- Сильный варпинг — нужна закрытая камера с подогревом
- Адгезия — клей-карандаш или PVA на стол
### Применение
Шестерни, втулки, подшипники скольжения, крепёжные элементы, функциональные петли, автозапчасти.
---
## PC (поликарбонат)
**Температура печати:** 270310°C
**Стол:** 110130°C
**Термостойкость:** до 140°C
**Цена:** ~60 ₽/г
### Свойства
- **Максимальная термостойкость** среди FDM-пластиков
- **Ударопрочность** — не разбивается при падении
- **Оптическая прозрачность** (натуральный цвет)
- **UV-стойкость** — не разрушается на солнце
### Особенности печати
- Требует высоких температур — не каждый принтер справится
- Обязательна закрытая камера с подогревом до 60°C+
- Сильный варпинг — нужен хорошо прогретый стол
### Применение
Корпуса для горячих сред (рядом с двигателем), защитные кожухи, электротехнические корпуса, светорассеиватели.
---
## TPU — термопластичный полиуретан
**Температура печати:** 220250°C
**Стол:** 4060°C
**Термостойкость:** до 80°C
**Цена:** ~40 ₽/г
**Твёрдость по Шору:** 85A95A
### Свойства
- **Эластичность** — растягивается и возвращает форму
- **Ударопрочность** — поглощает вибрации
- **Износостойкость** — не истирается
- **Химстойкость** — масла, жиры, многие растворители
### Особенности печати
- Медленная печать (1530 мм/с) — мягкий материал застревает в экструдере
- Директ-экструдер обязателен — боуден не справится
- Ретракт минимальный или отключён
### Применение
Прокладки, уплотнители, амортизаторы, защитные бамперы, гибкие петли, чехлы, вибродемпферы.
---
## PA-CF — нейлон с углеволокном
**Температура печати:** 270300°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:** бинарный файл в 510 раз меньше — используйте его
### 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.43.2 мм
- **С рёбрами жёсткости:** стенки 2.0 мм + рёбра 1.6 мм
### Крепёж
- **Саморезы:** отверстия диаметром на 0.5 мм меньше самореза
- **Вставки с резьбой (heat-set inserts):** запаиваются паяльником. Диаметр отверстия = внешний диаметр вставки - 0.2 мм
- **Защёлки (snap-fit):** работают отлично с PETG и нейлоном
### Вентиляция
- Решётки с перемычками 0.81.2 мм и щелями 1.52.0 мм
- Гексагональный паттерн — максимальная вентиляция при минимальной потере жёсткости
### Допуски
- **Свободная посадка:** +0.3 мм на сторону
- **Плотная посадка:** +0.1 мм на сторону
- **Прессовая посадка:** -0.05 мм на сторону (для PLA)
### IP-защита
Для достижения IP54 и выше:
- Печать с 100% заполнением для стенок
- Уплотнительная канавка для силиконовой прокладки
- Ориентация печати — минимум горизонтальных швов
## Ориентация печати
Ориентация детали на столе влияет на:
- **Прочность** — слои межслойной адгезии слабее
- **Качество поверхности** — верх и низ гладкие, бока видна слоистость
- **Поддержки** — минимизируйте их количество
**Рекомендация:** кладите корпус «открытой стороной вверх» — внутренняя поверхность менее критична.
## Стоимость
Типичный корпус 120×80×35 мм:
- **PLA:** ~1 0001 500 ₽
- **PETG:** ~1 2001 800 ₽
- **ABS:** ~1 0001 500 ₽
- **Нейлон:** ~2 5003 500 ₽
Загрузите вашу модель в наш [калькулятор](/) для точного расчёта.
`,
},
{
slug: 'postobrabotka-3d-pechatnyh-detalej',
title: 'Постобработка 3D-печатных деталей: шлифовка, покраска, ацетон, резьба',
description: 'Методы финишной обработки 3D-напечатанных деталей: механическая обработка, химическое сглаживание, покраска, нарезка резьбы.',
category: 'Руководства',
date: '2026-03-21',
readTime: 9,
image: null,
content: `
## Зачем нужна постобработка
FDM-печать оставляет видимую слоистость. Если деталь — функциональная запчасть внутри прибора, это не критично. Но если это корпус или визуальный прототип — постобработка обязательна.
## Шлифовка
Самый простой и универсальный метод.
### Процесс
1. **Грубая шлифовка** — наждачная бумага P80P120. Убираем слоистость
2. **Средняя** — P240P400. Выравниваем поверхность
3. **Финишная** — P600P1000. Подготовка под покраску
### Советы
- Шлифуйте **с водой** — меньше пыли, лучше результат
- PLA шлифуется легко, PETG — средне, нейлон — тяжело (мягкий, забивает бумагу)
- Круглые формы удобнее обрабатывать на вращении (дрель + оправка)
### Стоимость в нашем сервисе: 300 ₽/деталь
---
## Ацетоновое сглаживание (только ABS)
Пары ацетона растворяют поверхность ABS, создавая идеально гладкую, глянцевую поверхность.
### Процесс
1. Поместить деталь в герметичную ёмкость
2. На дно — тряпка, смоченная ацетоном (не заливать!)
3. Выдержать 1560 минут (контролировать визуально)
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 — стандартные размеры
- Температура: 200220°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. Материал (3050% стоимости)
Стоимость зависит от:
- **Объёма детали** — чем больше модель, тем больше пластика
- **Процента заполнения** — 100% заполнение стоит в 23 раза больше, чем 20%
- **Типа материала** — PLA/ABS: 25 ₽/г, нейлон: 50 ₽/г, PA-CF: 75 ₽/г
### 2. Время печати (3040% стоимости)
- Амортизация принтера
- Электроэнергия
- Оператор
**Факторы:** объём модели, высота слоя, скорость печати, количество поддержек.
Типичная ставка: **200 ₽/час**.
### 3. Постобработка (030% стоимости)
- Шлифовка: 300 ₽
- Покраска: 500 ₽
- Нарезка резьбы: 200 ₽/отверстие
- Ацетоновая обработка: 400 ₽
## Как сэкономить
### Снизить заполнение
Для нефункциональных деталей 1520% заполнения достаточно. Это снижает и расход материала, и время печати.
### Увеличить высоту слоя
- 0.20 мм — стандарт
- 0.28 мм — на 30% быстрее, разница в качестве минимальна для непрезентационных деталей
### Заказать партию
Скидки за количество:
- 25 шт: **5%**
- 620 шт: **10%**
- 21100 шт: **15%**
- 101500 шт: **20%**
### Выбрать бюджетный материал
PLA и ABS — 25 ₽/г. Если нет требований по температуре или прочности — зачем платить больше?
### Оптимизировать модель
- Уменьшить толщину стенок до разумного минимума (23 мм)
- Добавить рёбра жёсткости вместо толстых стенок
- Уменьшить количество поддержек (ориентация модели)
## Примеры цен
| Деталь | Размер | Материал | Цена |
|--------|--------|----------|------|
| Крышка для Arduino | 70×50×15 мм | PLA | 350500 ₽ |
| Корпус датчика | 120×80×35 мм | PETG | 1 2001 800 ₽ |
| Шестерня | 40×40×15 мм | PA (Nylon) | 8001 200 ₽ |
| Кронштейн | 100×60×40 мм | PA-CF | 2 5003 500 ₽ |
| Прокладка | 80×80×5 мм | TPU | 400600 ₽ |
*Точная цена зависит от геометрии. [Загрузите модель](/) — расчёт за секунды.*
`,
},
{
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 ₽ |
*Пересечение — при 5001000 шт. Для меньших тиражей 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))]

View 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 }
})

View 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>

View 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>

View 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) }} &#8381;</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>

View 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>

View 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>

View 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 }} &#8381;</td>
<td class="px-4 py-3 text-right text-gray-700">{{ m.density_g_cm3 }} г/см&sup3;</td>
<td class="px-4 py-3 text-center">
<span v-if="m.is_active" class="text-green-600">&#10003;</span>
<span v-else class="text-gray-300">&#10005;</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">Цена за грамм (&#8381;)</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">Плотность (г/см&sup3;)</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">Скорость потока (мм&sup3;/с)</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">Макс. темп. (&deg;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">Мин. темп. (&deg;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>

View 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"> &middot; {{ order.client_company }}</span>
</div>
<div class="mt-1 text-xs text-gray-400">
{{ order.client_phone }}
<span v-if="order.material_name"> &middot; {{ order.material_name }}</span>
<span v-if="order.quantity"> &middot; {{ order.quantity }} шт</span>
<span> &middot; {{ formatDate(order.created_at) }}</span>
</div>
</div>
<div class="text-right">
<p class="text-lg font-bold text-gray-900">{{ fmt(order.total_rub) }} &#8381;</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 }} см&sup3;</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) }} &#8381;</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>

View 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>

View 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">&#10003;</span>
<span v-else class="text-red-400">&#10005;</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>