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