17 октября 2025 г.
Представления
Файл src/views/Home.vue
<template>
<div v-if="loading" class="loading">
<Loader />
</div>
<Slider v-else></Slider>
</template>
<script>
import Loader from '../components/Loader.vue';
import Slider from '../components/Slider.vue';
export default {
name: 'HomeView',
data() {
return {
loading: false,
pageId: null
}
},
components: {
Loader,
Slider
}
}
</script>
Файл src/views/PageDetail.vue
<template>
<PageViewer :page-id="pageId" />
</template>
<script>
import PageViewer from '../components/PageViewer.vue'
export default {
name: 'PageDetail',
components: {
PageViewer
},
computed: {
pageId() {
return this.$route.params.id
}
}
}
</script>
Файл src/views/Portfolio.vue
<template>
<div class="title d-flex justify-content-center align-items-center ">
<h1 class="text-center">Портфолио
<p>Профессиональная разработка и производство качественной мебели для дома и офиса.</p>
</h1>
</div>
<div class="container mt-10 mb-10">
<div v-if="loading" class="text-center">
<Loader />
</div>
<div v-else-if="portfolioItems.length === 0" class="d-flex justify-content-center vh-50 text-center align-items-center">
<h1 class="not-found">Портфолио пусто<p>В портфолио пока нет работ</p></h1>
</div>
<div v-else class="row g-3">
<div
v-for="item in portfolioItems"
:key="item.id"
class="col-md-6 col-lg-4"
>
<div class="card h-100 portfolio-card" @click="openModal(item)">
<img
:src="getFirstImageUrl(item.image)"
:alt="item.title"
class="card-img-top portfolio-image"
>
<div class="portfolio-overlay">
<h5 class="card-title mb-0">{{ item.title }}</h5>
<span class="badge bg-secondary ms-2">
{{ item.image.length }} фото
</span>
</div>
<div class="card-body">
<p class="card-text text-muted small">
{{ truncateHtml(item.text, 80) }}
</p>
</div>
</div>
</div>
</div>
<div
v-if="selectedItem"
class="modal fade show portfolio-modal"
tabindex="-1"
style="display: block; background: rgba(0,0,0,0.8)"
@click="closeModal"
>
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content" @click.stop>
<div class="modal-header">
<h5 class="modal-title">{{ selectedItem.title }}</h5>
<button type="button" class="btn-close btn-close-dark" @click="closeModal"></button>
</div>
<div class="modal-body">
<div class="carousel-opacity">
<div class="carousel-container">
<transition :name="animationType" mode="out-in">
<div
:key="currentImageIndex"
class="carousel-slide"
>
<img
:src="getImageUrl(selectedItem.image[currentImageIndex])"
:alt="selectedItem.title"
class="carousel-image"
>
</div>
</transition>
</div>
<button
v-if="selectedItem.image.length > 1"
class="carousel-control-prev"
type="button"
@click="prevSlide"
>
<span class="carousel-control-prev-icon" aria-hidden="true"></span>
<span class="visually-hidden">Предыдущий</span>
</button>
<button
v-if="selectedItem.image.length > 1"
class="carousel-control-next"
type="button"
@click="nextSlide"
>
<span class="carousel-control-next-icon" aria-hidden="true"></span>
<span class="visually-hidden">Следующий</span>
</button>
</div>
<div
class="mt-3 portfolio-modal-text"
v-if="selectedItem.text"
v-html="selectedItem.text"
></div>
</div>
<div class="modal-footer">
<div class="me-auto">
<small class="text-muted">
Показано {{ currentImageIndex + 1 }} из {{ selectedItem.image.length }}
</small>
</div>
<button type="button" class="btn btn-secondary" @click="closeModal">Закрыть</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { apiService } from '../services/api'
import Loader from '../components/Loader.vue'
import { getImageUrl } from '../utils/imageUrl'
export default {
name: 'PortfolioView',
components: {
Loader
},
data() {
return {
portfolioItems: [],
loading: false,
selectedItem: null,
currentImageIndex: 0,
animationType: import.meta.env.VITE_ANIMATION_TYPE || 'slide-next'
}
},
async mounted() {
await this.loadPortfolio()
this.validateAnimationType();
},
methods: {
async loadPortfolio() {
this.loading = true
try {
const response = await apiService.getPortfolio()
this.portfolioItems = response.data
} catch (error) {
console.error('Ошибка загрузки галереи:', error)
this.$notify({
type: 'error',
title: 'Ошибка',
text: 'Не удалось загрузить галерею'
})
} finally {
this.loading = false
}
},
validateAnimationType() {
const validAnimations = ['slide-next', 'fade', 'zoom', 'slide-up'];
if (!validAnimations.includes(this.animationType)) {
console.warn(`Неверный тип анимации: ${this.animationType}. Используется значение по умолчанию: slide-next`);
this.animationType = 'slide-next';
}
},
getImageUrl(imageData) {
return getImageUrl(imageData);
},
getFirstImageUrl(images) {
return images.length > 0 ? this.getImageUrl(images[0]) : '/placeholder-image.jpg'
},
openModal(item) {
this.selectedItem = item
this.currentImageIndex = 0
document.body.style.overflow = 'hidden'
},
closeModal() {
this.selectedItem = null
this.currentImageIndex = 0
document.body.style.overflow = 'auto'
},
nextSlide() {
if (this.selectedItem && this.selectedItem.image.length > 1) {
this.currentImageIndex = (this.currentImageIndex + 1) % this.selectedItem.image.length
}
},
prevSlide() {
if (this.selectedItem && this.selectedItem.image.length > 1) {
this.currentImageIndex = (this.currentImageIndex - 1 + this.selectedItem.image.length) % this.selectedItem.image.length
}
},
truncateText(text, length) {
if (!text) return ''
return text.length > length ? text.substring(0, length) + '...' : text
},
truncateHtml(html, length) {
if (!html) return ''
const tempDiv = document.createElement('div')
tempDiv.innerHTML = this.sanitizeHtml(html)
const text = tempDiv.textContent || tempDiv.innerText || ''
if (text.length <= length) {
return this.sanitizeHtml(html)
}
return this.sanitizeHtml(text.substring(0, length) + '...')
},
sanitizeHtml(html) {
if (!html) return ''
if (typeof DOMPurify !== 'undefined' && DOMPurify.sanitize) {
return DOMPurify.sanitize(html)
}
return html
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/on\w+="[^"]*"/g, '')
.replace(/on\w+='[^']*'/g, '')
.replace(/javascript:/gi, '')
}
}
}
</script>
<style scoped>
.mt-10 {
margin-top: 2rem !important;
}
.mb-10 {
margin-bottom: 8rem !important;
}
.vh-50 {
height: 50vh !important;
}
.portfolio-card {
cursor: pointer;
transition: transform 0.2s ease;
border-radius: 0;
}
.portfolio-card:hover {
transform: translateY(-5px);
}
.portfolio-image {
height: 200px;
object-fit: cover;
}
.portfolio-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
background: linear-gradient(to bottom, rgba(0,0,0,0.7) 0%, transparent 100%);
color: white;
padding: 15px;
display: flex;
align-items: center;
justify-content: space-between;
}
.portfolio-modal .modal-content {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 0;
}
.carousel-opacity {
position: relative;
width: 100%;
height: 70vh;
overflow: hidden;
}
.carousel-container {
position: relative;
width: 100%;
height: 100%;
}
.carousel-slide {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.carousel-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 8px;
}
.slide-next-enter-active,
.slide-next-leave-active {
transition: all 0.5s ease;
}
.slide-next-enter-from {
opacity: 0;
transform: translateX(100%);
}
.slide-next-leave-to {
opacity: 0;
transform: translateX(-100%);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* Zoom анимация */
.zoom-enter-active,
.zoom-leave-active {
transition: all 0.5s ease;
}
.zoom-enter-from {
opacity: 0;
transform: scale(0.9);
}
.zoom-leave-to {
opacity: 0;
transform: scale(1.1);
}
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.5s ease;
}
.slide-up-enter-from {
opacity: 0;
transform: translateY(100%);
}
.slide-up-leave-to {
opacity: 0;
transform: translateY(-100%);
}
</style>
Файл src/views/Contact.vue
<template>
<div v-if="loading" class="d-flex justify-content-center align-items-center">
<div class="text-center"><Loader /></div>
</div>
<div v-else-if="page && page.title" class="title d-flex justify-content-center align-items-center ">
<h1 class="text-center">{{ page.title }}
<p v-html="page.description"></p>
</h1>
</div>
<div v-if="!loading && page && page.title" class="container mt-10">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-6">
<div v-if="loading">
<div class="container mt-10">
<div class="text-center"><Loader /></div>
</div>
</div>
<div v-else-if="page">
<div v-html="page.text" class="page-body"></div>
</div>
<div v-else>
<div class="container">
<div class="d-flex justify-content-center align-items-center">
<h1 class="not-found">Страница не найдена<p>Запрошенная страница не существует или временно недоступна</p></h1>
</div>
</div>
</div>
</div>
<div class="col-md-6 col-lg-6">
<div class="card mb-10">
<div class="card-header">
<h3 class="card-title mb-0">Контактная форма</h3>
</div>
<div class="card-body">
<form @submit.prevent="submitForm" >
<div v-if="successMessage" class="alert alert-success mb-3">
{{ successMessage }}
</div>
<div v-if="errorMessage" class="alert alert-danger mb-3">
{{ errorMessage }}
</div>
<div class="mb-3">
<label for="name" class="form-label">Имя *</label>
<input
type="text"
class="form-control"
:class="{ 'is-invalid': errors.name }"
id="name"
v-model="form.name"
required
>
<div v-if="errors.name" class="invalid-feedback">
{{ errors.name }}
</div>
</div>
<div class="mb-3">
<label for="email" class="form-label">Email *</label>
<input
type="email"
class="form-control"
:class="{ 'is-invalid': errors.email }"
id="email"
v-model="form.email"
required
>
<div v-if="errors.email" class="invalid-feedback">
{{ errors.email }}
</div>
</div>
<div class="mb-3">
<label for="subject" class="form-label">Тема *</label>
<input
type="text"
class="form-control"
:class="{ 'is-invalid': errors.subject }"
id="subject"
v-model="form.subject"
required
>
<div v-if="errors.subject" class="invalid-feedback">
{{ errors.subject }}
</div>
</div>
<div class="mb-3">
<label for="message" class="form-label">Сообщение *</label>
<textarea
class="form-control"
:class="{ 'is-invalid': errors.message }"
id="message"
rows="5"
v-model="form.message"
required
></textarea>
<div v-if="errors.message" class="invalid-feedback">
{{ errors.message }}
</div>
</div>
<button
type="submit"
class="btn btn-primary w-100"
:disabled="formLoading"
>
<span v-if="formLoading" class="spinner-border spinner-border-sm me-2"></span>
{{ formLoading ? 'Отправка...' : 'Отправить сообщение' }}
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { apiService } from '../services/api'
import Loader from '../components/Loader.vue'
export default {
name: 'ContactForm',
components: {
Loader
},
data() {
return {
page: null,
loading: false,
error: null,
form: {
name: '',
email: '',
subject: '',
message: ''
},
errors: {},
formLoading: false,
successMessage: '',
errorMessage: ''
}
},
async mounted() {
await this.loadPage()
},
methods: {
validateForm() {
this.errors = {}
let isValid = true
if (!this.form.name.trim()) {
this.errors.name = 'Введите ваше имя'
isValid = false
}
if (!this.form.email.trim()) {
this.errors.email = 'Введите email адрес'
isValid = false
} else if (!this.isValidEmail(this.form.email)) {
this.errors.email = 'Введите корректный email адрес'
isValid = false
}
if (!this.form.subject.trim()) {
this.errors.subject = 'Введите тему сообщения'
isValid = false
}
if (!this.form.message.trim()) {
this.errors.message = 'Введите текст сообщения'
isValid = false
}
return isValid
},
isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
},
async submitForm() {
if (!this.validateForm()) return
this.formLoading = true
this.successMessage = ''
this.errorMessage = ''
try {
await apiService.sendContact(this.form)
this.successMessage = 'Ваше сообщение успешно отправлено! Мы свяжемся с вами в ближайшее время.'
this.form = { name: '', email: '', subject: '', message: '' }
this.errors = {}
} catch (error) {
console.error('Ошибка отправки формы:', error)
this.errorMessage = 'Произошла ошибка при отправке сообщения. Пожалуйста, попробуйте позже.'
} finally {
this.formLoading = false
}
},
async loadPage() {
this.loading = true
this.error = null
try {
const response = await apiService.getPage('contact')
this.page = response.data || response
} catch (error) {
console.error('Ошибка загрузки страницы:', error)
this.error = 'Не удалось загрузить страницу'
} finally {
this.loading = false
}
}
}
}
</script>
<style scoped>
.mt-10 {
margin-top: 2rem !important;
}
.mb-10 {
margin-bottom: 8rem !important;
}
.card {
border-radius: 0;
}
</style>
Стили
Файлы
src/assets/css/main.css и
src/assets/css/slider.css качаем соответственно.
Тестирование
Запуск:
npm run dev
Сайт будет доступен:
http://localhost:5173/
Демо-версия доступна:
https://demo-furniture-factory.jcms.ru/
Сборка
Выполним команду:
npm run build