This commit is contained in:
xds
2026-03-22 13:26:38 +03:00
parent f98c57a433
commit beb14b4e43
11 changed files with 272 additions and 62 deletions

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ dist/
uploads/ uploads/
*.egg-info/ *.egg-info/
.DS_Store .DS_Store
.idea

View File

@@ -15,6 +15,12 @@ class Settings(BaseSettings):
MINIO_BUCKET: str = "filam3d" MINIO_BUCKET: str = "filam3d"
MINIO_SECURE: bool = False MINIO_SECURE: bool = False
JWT_SECRET: str = "change-me-in-production-please"
JWT_ALGORITHM: str = "HS256"
JWT_EXPIRE_HOURS: int = 24
ADMIN_DEFAULT_EMAIL: str = "admin@filam3d.ru"
ADMIN_DEFAULT_PASSWORD: str = "admin123"
model_config = {"env_file": ["../.env", ".env"]} model_config = {"env_file": ["../.env", ".env"]}

View File

@@ -6,10 +6,12 @@ from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import select from sqlalchemy import select
from app.config import settings
from app.database import async_session, engine, Base from app.database import async_session, engine, Base
from app.models import Material from app.models import Material, AdminUser
from app.seed.materials import MATERIALS from app.seed.materials import MATERIALS
from app.routers import calculate, materials, orders, ai_advisor from app.services.auth import hash_password
from app.routers import calculate, materials, orders, ai_advisor, admin
# Configure logging # Configure logging
logging.basicConfig( logging.basicConfig(
@@ -50,6 +52,22 @@ async def lifespan(app: FastAPI):
else: else:
logger.info("Materials already exist, skipping seed") logger.info("Materials already exist, skipping seed")
# Seed default admin user
logger.info("Checking admin user...")
async with async_session() as session:
result = await session.execute(select(AdminUser).limit(1))
if result.scalar_one_or_none() is None:
admin_user = AdminUser(
email=settings.ADMIN_DEFAULT_EMAIL,
password_hash=hash_password(settings.ADMIN_DEFAULT_PASSWORD),
name="Admin",
)
session.add(admin_user)
await session.commit()
logger.info("Default admin created: %s", settings.ADMIN_DEFAULT_EMAIL)
else:
logger.info("Admin user already exists, skipping")
logger.info("=== Application ready ===") logger.info("=== Application ready ===")
yield yield
logger.info("=== Application shutdown ===") logger.info("=== Application shutdown ===")
@@ -82,6 +100,7 @@ app.include_router(calculate.router, prefix="/api")
app.include_router(materials.router, prefix="/api") app.include_router(materials.router, prefix="/api")
app.include_router(orders.router, prefix="/api") app.include_router(orders.router, prefix="/api")
app.include_router(ai_advisor.router, prefix="/api") app.include_router(ai_advisor.router, prefix="/api")
app.include_router(admin.router, prefix="/api/admin")
@app.get("/api/health") @app.get("/api/health")

View File

@@ -1,5 +1,7 @@
from app.models.material import Material from app.models.material import Material
from app.models.calculation import Calculation from app.models.calculation import Calculation
from app.models.order import Order from app.models.order import Order
from app.models.admin_user import AdminUser
from app.models.app_settings import AppSettings
__all__ = ["Material", "Calculation", "Order"] __all__ = ["Material", "Calculation", "Order", "AdminUser", "AppSettings"]

View File

@@ -11,3 +11,5 @@ python-multipart==0.0.20
httpx==0.28.1 httpx==0.28.1
minio==7.2.12 minio==7.2.12
google-genai==1.14.0 google-genai==1.14.0
pyjwt==2.10.1
bcrypt==4.2.1

View File

@@ -14,7 +14,3 @@ services:
MINIO_SECRET_KEY: SuperSecretPassword123! MINIO_SECRET_KEY: SuperSecretPassword123!
MINIO_BUCKET: ${MINIO_BUCKET:-filam3d} MINIO_BUCKET: ${MINIO_BUCKET:-filam3d}
MINIO_SECURE: ${MINIO_SECURE:-false} MINIO_SECURE: ${MINIO_SECURE:-false}
frontend:
build: ./frontend
network_mode: host

View File

@@ -1,12 +0,0 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev"]

View File

@@ -3,10 +3,28 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Сервис 3D-печати на заказ. Мгновенный расчёт стоимости по 3D-модели. PLA, PETG, ABS, нейлон, поликарбонат, TPU, композиты. AI-подбор материала. Доставка по России." />
<meta name="keywords" content="3D печать, 3D печать на заказ, калькулятор 3D печати, стоимость 3D печати, FDM печать, PLA, PETG, ABS, нейлон, поликарбонат, TPU, прототипирование, корпуса для электроники, 3D печать деталей" />
<meta name="author" content="Filam3D" />
<meta name="robots" content="index, follow" />
<link rel="canonical" href="https://filam3d.ru/" />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:title" content="Filam3D — 3D-печать на заказ с мгновенным расчётом" />
<meta property="og:description" content="Загрузите 3D-модель, выберите материал — получите цену за секунды. 7 материалов, AI-подбор, B2B." />
<meta property="og:locale" content="ru_RU" />
<meta property="og:site_name" content="Filam3D" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Filam3D — Калькулятор 3D-печати" />
<meta name="twitter:description" content="Мгновенный расчёт стоимости 3D-печати. Загрузите STL, выберите материал, получите цену." />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<title>Filam3D — Калькулятор 3D-печати</title> <title>Filam3D — 3D-печать на заказ | Калькулятор стоимости онлайн</title>
</head> </head>
<body class="bg-gray-50 font-sans text-gray-900 antialiased"> <body class="bg-gray-50 font-sans text-gray-900 antialiased">
<div id="app"></div> <div id="app"></div>

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="min-h-screen bg-gray-50"> <div class="flex min-h-screen flex-col bg-gray-50">
<header class="border-b border-gray-200 bg-white"> <header class="sticky top-0 z-40 border-b border-gray-200 bg-white/95 backdrop-blur">
<div class="mx-auto flex max-w-6xl items-center justify-between px-4 py-4 sm:px-6"> <div class="mx-auto flex max-w-6xl items-center justify-between px-4 py-3 sm:px-6">
<router-link to="/" class="flex items-center gap-2.5"> <router-link to="/" class="flex items-center gap-2.5">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary-600"> <div class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary-600">
<svg class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <svg class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
@@ -15,6 +15,7 @@
to="/" to="/"
class="rounded-lg px-3 py-2 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900" class="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" active-class="!bg-primary-50 !text-primary-700"
exact
> >
Калькулятор Калькулятор
</router-link> </router-link>
@@ -25,11 +26,23 @@
> >
Материалы Материалы
</router-link> </router-link>
<router-link
to="/blog"
class="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"
>
Блог
</router-link>
</nav> </nav>
</div> </div>
</header> </header>
<main class="mx-auto max-w-6xl px-4 py-8 sm:px-6"> <main class="mx-auto w-full max-w-6xl flex-1 px-4 py-8 sm:px-6">
<router-view /> <router-view />
</main> </main>
<SiteFooter />
</div> </div>
</template> </template>
<script setup>
import SiteFooter from './components/SiteFooter.vue'
</script>

View File

@@ -2,14 +2,109 @@ import { createRouter, createWebHistory } from 'vue-router'
import CalculatorView from '../views/CalculatorView.vue' import CalculatorView from '../views/CalculatorView.vue'
import MaterialsView from '../views/MaterialsView.vue' import MaterialsView from '../views/MaterialsView.vue'
import OrderView from '../views/OrderView.vue' import OrderView from '../views/OrderView.vue'
import BlogView from '../views/BlogView.vue'
import ArticleView from '../views/ArticleView.vue'
import AdminLogin from '../views/admin/AdminLogin.vue'
import AdminLayout from '../views/admin/AdminLayout.vue'
import AdminDashboard from '../views/admin/AdminDashboard.vue'
import AdminOrders from '../views/admin/AdminOrders.vue'
import AdminMaterials from '../views/admin/AdminMaterials.vue'
import AdminSettings from '../views/admin/AdminSettings.vue'
import AdminUsers from '../views/admin/AdminUsers.vue'
const routes = [ const routes = [
{ path: '/', name: 'calculator', component: CalculatorView }, {
{ path: '/materials', name: 'materials', component: MaterialsView }, path: '/',
{ path: '/order/:calcId', name: 'order', component: OrderView }, name: 'calculator',
component: CalculatorView,
meta: { title: 'Калькулятор 3D-печати — Filam3D' },
},
{
path: '/materials',
name: 'materials',
component: MaterialsView,
meta: { title: 'Материалы для 3D-печати — Filam3D' },
},
{
path: '/order/:calcId',
name: 'order',
component: OrderView,
meta: { title: 'Оформление заказа — Filam3D' },
},
{
path: '/blog',
name: 'blog',
component: BlogView,
meta: { title: 'Блог о 3D-печати — статьи и руководства — Filam3D' },
},
{
path: '/blog/:slug',
name: 'article',
component: ArticleView,
},
{
path: '/admin/login',
name: 'admin-login',
component: AdminLogin,
meta: { title: 'Вход — Админ-панель Filam3D' },
},
{
path: '/admin',
component: AdminLayout,
meta: { requiresAdmin: true },
children: [
{
path: '',
name: 'admin-dashboard',
component: AdminDashboard,
meta: { title: 'Дашборд — Админ-панель Filam3D' },
},
{
path: 'orders',
name: 'admin-orders',
component: AdminOrders,
meta: { title: 'Заказы — Админ-панель Filam3D' },
},
{
path: 'materials',
name: 'admin-materials',
component: AdminMaterials,
meta: { title: 'Материалы — Админ-панель Filam3D' },
},
{
path: 'settings',
name: 'admin-settings',
component: AdminSettings,
meta: { title: 'Настройки — Админ-панель Filam3D' },
},
{
path: 'users',
name: 'admin-users',
component: AdminUsers,
meta: { title: 'Администраторы — Админ-панель Filam3D' },
},
],
},
] ]
export default createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes, routes,
scrollBehavior() {
return { top: 0 }
},
}) })
router.beforeEach((to) => {
if (to.meta.title) {
document.title = to.meta.title
}
if (to.meta.requiresAdmin || to.matched.some((r) => r.meta.requiresAdmin)) {
const token = localStorage.getItem('admin_token')
if (!token) {
return { name: 'admin-login' }
}
}
})
export default router

View File

@@ -1,58 +1,128 @@
<template> <template>
<div> <div>
<div class="mb-6"> <HeroSection />
<h1 class="text-2xl font-bold text-gray-900">Калькулятор 3D-печати</h1>
<p class="mt-1 text-sm text-gray-500">Загрузите модель, выберите материал и получите мгновенный расчёт стоимости</p>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3"> <!-- Calculator section -->
<div class="lg:col-span-2 space-y-6"> <section id="calculator" class="mb-12">
<FileUploader /> <div class="mb-6">
<MaterialPicker @open-advisor="showAdvisor = true" /> <h2 class="text-2xl font-bold text-gray-900">Калькулятор стоимости</h2>
<PrintSettings /> <p class="mt-1 text-sm text-gray-500">Загрузите модель, выберите материал и получите мгновенный расчёт</p>
<div>
<button
@click="store.calculate()"
:disabled="!store.file || !store.materialId || store.loading"
class="btn-primary w-full sm:w-auto"
>
<svg v-if="store.loading" class="mr-2 h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{{ store.loading ? 'Считаем...' : 'Рассчитать стоимость' }}
</button>
<p v-if="store.error" class="mt-2 text-sm text-red-600">{{ store.error }}</p>
</div>
</div> </div>
<div class="lg:col-span-1"> <div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div class="sticky top-8"> <div class="lg:col-span-2 space-y-6">
<PriceResult /> <FileUploader />
<div v-if="!store.result" class="card text-center text-sm text-gray-400"> <MaterialPicker @open-advisor="showAdvisor = true" />
<svg class="mx-auto mb-3 h-12 w-12 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1"> <PrintSettings />
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 15.75V18m-7.5-6.75h.008v.008H8.25v-.008zm0 2.25h.008v.008H8.25v-.008zm0 2.25h.008v.008H8.25v-.008zm0 2.25h.008v.008H8.25v-.008zm2.25-4.5h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008v-.008zm2.25-4.5h.008v.008H12.75v-.008zm0 2.25h.008v.008H12.75v-.008zm0 2.25h.008v.008H12.75v-.008zm0 2.25h.008v.008H12.75v-.008zm2.25-4.5h.008v.008H15v-.008zm0 2.25h.008v.008H15v-.008zm0 2.25h.008v.008H15v-.008z" />
</svg> <div>
<p>Загрузите модель и выберите материал для расчёта</p> <button
@click="store.calculate()"
:disabled="!store.file || !store.materialId || store.loading"
class="btn-primary w-full sm:w-auto"
>
<svg v-if="store.loading" class="mr-2 h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{{ store.loading ? 'Считаем...' : 'Рассчитать стоимость' }}
</button>
<p v-if="store.error" class="mt-2 text-sm text-red-600">{{ store.error }}</p>
</div>
</div>
<div class="lg:col-span-1">
<div class="sticky top-20">
<PriceResult />
<div v-if="!store.result" class="card text-center text-sm text-gray-400">
<svg class="mx-auto mb-3 h-12 w-12 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 15.75V18m-7.5-6.75h.008v.008H8.25v-.008zm0 2.25h.008v.008H8.25v-.008zm0 2.25h.008v.008H8.25v-.008zm0 2.25h.008v.008H8.25v-.008zm2.25-4.5h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008v-.008zm2.25-4.5h.008v.008H12.75v-.008zm0 2.25h.008v.008H12.75v-.008zm0 2.25h.008v.008H12.75v-.008zm0 2.25h.008v.008H12.75v-.008zm2.25-4.5h.008v.008H15v-.008zm0 2.25h.008v.008H15v-.008zm0 2.25h.008v.008H15v-.008z" />
</svg>
<p>Загрузите модель и выберите материал для расчёта</p>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </section>
<!-- Features -->
<section class="mb-12">
<h2 class="mb-6 text-2xl font-bold text-gray-900">Как это работает</h2>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-3">
<div class="card text-center">
<div class="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-xl bg-primary-100">
<svg class="h-6 w-6 text-primary-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
</div>
<h3 class="mb-1 text-sm font-bold text-gray-900">Загрузите модель</h3>
<p class="text-xs text-gray-500">STL, 3MF или OBJ файл до 50 МБ. Drag-and-drop или выбор файла.</p>
</div>
<div class="card text-center">
<div class="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-xl bg-primary-100">
<svg class="h-6 w-6 text-primary-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<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" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<h3 class="mb-1 text-sm font-bold text-gray-900">Настройте параметры</h3>
<p class="text-xs text-gray-500">Выберите материал, заполнение, высоту слоя. AI поможет с выбором.</p>
</div>
<div class="card text-center">
<div class="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-xl bg-primary-100">
<svg class="h-6 w-6 text-primary-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
</svg>
</div>
<h3 class="mb-1 text-sm font-bold text-gray-900">Получите цену</h3>
<p class="text-xs text-gray-500">Детальная разбивка стоимости. Оформите заказ в два клика.</p>
</div>
</div>
</section>
<!-- Latest articles -->
<section class="mb-8">
<div class="mb-6 flex items-center justify-between">
<h2 class="text-2xl font-bold text-gray-900">Полезные статьи</h2>
<router-link to="/blog" class="text-sm font-medium text-primary-600 hover:text-primary-700">
Все статьи &rarr;
</router-link>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<router-link
v-for="article in latestArticles"
:key="article.slug"
:to="`/blog/${article.slug}`"
class="card group transition-shadow hover:shadow-md"
>
<span class="text-xs font-medium text-primary-600">{{ article.category }}</span>
<h3 class="mt-1 text-sm font-bold text-gray-900 group-hover:text-primary-600 transition-colors leading-snug">
{{ article.title }}
</h3>
<p class="mt-1.5 text-xs text-gray-500 line-clamp-2">{{ article.description }}</p>
</router-link>
</div>
</section>
<AiAdvisor :open="showAdvisor" @close="showAdvisor = false" /> <AiAdvisor :open="showAdvisor" @close="showAdvisor = false" />
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { ref, computed } from 'vue'
import { useCalculatorStore } from '../stores/calculator' import { useCalculatorStore } from '../stores/calculator'
import HeroSection from '../components/HeroSection.vue'
import FileUploader from '../components/FileUploader.vue' import FileUploader from '../components/FileUploader.vue'
import MaterialPicker from '../components/MaterialPicker.vue' import MaterialPicker from '../components/MaterialPicker.vue'
import PrintSettings from '../components/PrintSettings.vue' import PrintSettings from '../components/PrintSettings.vue'
import PriceResult from '../components/PriceResult.vue' import PriceResult from '../components/PriceResult.vue'
import AiAdvisor from '../components/AiAdvisor.vue' import AiAdvisor from '../components/AiAdvisor.vue'
import { articles } from '../data/articles'
const store = useCalculatorStore() const store = useCalculatorStore()
const showAdvisor = ref(false) const showAdvisor = ref(false)
const latestArticles = computed(() =>
[...articles].sort((a, b) => b.date.localeCompare(a.date)).slice(0, 3)
)
</script> </script>