Тест на вакансию

Создание простого приложение на Vue.js (часть 2)

17 октября 2025 г.
153

Представления

Файл 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
Поделиться: