init
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ dist/
|
|||||||
uploads/
|
uploads/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.idea
|
||||||
@@ -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"]}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
FROM node:20-alpine
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY package.json .
|
|
||||||
RUN npm install
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
EXPOSE 5173
|
|
||||||
|
|
||||||
CMD ["npm", "run", "dev"]
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
Все статьи →
|
||||||
|
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user