Перейти к содержанию

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

В директории /views будем хранить большие компоненты со сложной логикой, которые будут использовать другие компоненты и будут являться полноценным представлением для конкретной страницы.

views/Login.vue
<script setup>
import{ ref, onMounted } from 'vue';

import { fetchWrapper } from '@/helpers';
import { useAuthStore } from '@/stores';
import { LoginButton, LoadingScreen } from "@/components";

const baseUrl = `${import.meta.env.VITE_API_URL}`;
const disabled = ref(false)

const authenticate = async () => {
  disabled.value = true;
  const response = await fetchWrapper.get(`${baseUrl}/oidc/auth/url/`);
  window.location.href = response.url;
};

onMounted(async () => {
  const urlParams = new URLSearchParams(window.location.search);
  const code = urlParams.get('code');

  if (code) {
    disabled.value = true;
    await login(code);
  }
});

const login = async (code) => {
  const authStore = useAuthStore();

  return authStore.login(code)
};
</script>

<template>
  <LoginButton @click="authenticate" :disabled="disabled"/>
  <LoadingScreen v-if="disabled" />
</template>

<style scoped>

</style>

В шаблоне этого компонента будем выводить кнопку входа и загрузочный экран, отключенный по умолчанию.

При клике на кнопку будем отправлять запрос в бэкенд на получение ссылки на авторизации, после чего будем переводить пользователя на неё.

После успешной авторизации пользователь будет переведён обратно на эту же страницу с query параметром code, который нужно отправить обратно на бэкенд.

Для этого при загрузке страницы будем проверять наличие code, и, если он есть, будем отправлять отправлять вызывать функцию login хранилища авторизации с этим аргументом.

views/Survey.vue
<script setup>
import { ref, onMounted } from 'vue'

import { fetchWrapper, router } from '@/helpers';
import { useInfoStore } from '@/stores';

import { AdapterSlider, Select, Slider, SubmitButton, TextArea, TrainingSelect } from "@/components";

const baseUrl = `${import.meta.env.VITE_API_URL}`;
let isSending = ref(false);

const onSubmit = async () => {
  if (errors.value.length > 0) {
    return;
  }

  isSending.value = true;
  const info = useInfoStore();

  const formValuesList = Object.keys(formValues).map(key => {
    if (typeof formValues[key] === 'object' && formValues[key] !== null) {
      return {
        id: parseInt(key),
        value: Object.keys(formValues[key]).map(innerKey => ({
          id: parseInt(innerKey),
          value: formValues[key][innerKey]
        }))
      };
    }
    else {
      return {
        id: parseInt(key),
        value: formValues[key]
      };
    }
  });

  try {
    await fetchWrapper.post(`${baseUrl}/api/survey/answers/`, formValuesList);
    info.setText('Опрос пройден!\nСпасибо 💙');
  } catch (error) {
    info.setText('Произошла ошибка при сохранении ответов!');
  }
  await router.push('/info');
}

const formFields = ref([])
const formValues = {}
const errors = ref([])

onMounted(async () => {
  try {
    const data = await fetchWrapper.get(`${baseUrl}/api/survey/questions/`)
    if(data) {
      formFields.value = data
      data.forEach((field) => {
        formValues[field.id] = ''
        if (field.component === 'Select' || field.component === 'TrainingSelect') {
          errors.value.push(field.id)
        }
      })
    }
  } catch(err) {
    console.error(err)
  }
})
</script>

<template>
  <div class="form-container">
    <v-form @submit.prevent="onSubmit" class="form-content">
      <div class="field" v-for="(field, index) in formFields" :key="index">
        <TextArea
          v-if="field.component === 'TextArea'"
          :id="`${field.id}`"
          :text="field.text"
          :helpText="field.help_text"
          @update:textArea="val => {
            formValues[field.id] = val
          }"
        />
        <Select
          v-else-if="field.component === 'Select'"
          :id="`${field.id}`"
          :text="field.text"
          :helpText="field.help_text"
          @update:selectedValue="val => formValues[field.id] = val"
          @update:error="val => {
            if (val) {
              if (!errors.includes(field.id)) {
                errors.push(field.id)
              }
            } else {
              const index = errors.indexOf(field.id)
              if (index > -1) {
                errors.splice(index, 1)
              }
            }
          }"
        />
        <TrainingSelect
          v-else-if="field.component === 'TrainingSelect'"
          :id="`${field.id}`"
          :text="field.text"
          :helpText="field.help_text"
          :training-text="field.training_text"
          :training-help-text="field.training_help_text"
          @update:selectedValue="val => formValues[field.id] = val"
          @update:error="val => {
            if (val) {
              if (!errors.includes(field.id)) {
                errors.push(field.id)
              }
            } else {
              const index = errors.indexOf(field.id)
              if (index > -1) {
                errors.splice(index, 1)
              }
            }
          }"
        />
        <AdapterSlider
          v-else-if="field.component === 'AdapterSlider'"
          :id="`${field.id}`"
          :text="field.text"
          :helpText="field.help_text"
          :adapters="field.adapters"
          @update:sliderValue="val => formValues[field.id] = val"
        />
        <Slider
          v-else-if="field.component === 'Slider'"
          :id="`${field.id}`"
          :text="field.text"
          :helpText="field.help_text"
          @update:sliderValue="val => formValues[field.id] = val"
        />
      </div>
      <div class="center-button">
        <SubmitButton v-if="formFields.length>0" :disabled="errors.length>0 || isSending"/>
      </div>
    </v-form>
  </div>
</template>

<style scoped>
.form-container {
  display: flex;
  justify-content: center;
}
.form-content {
  padding-left: 20px;
  padding-right: 20px;
  max-width: 600px;
  width: 100%;
}
.center-button {
  display: flex;
  justify-content: center;
  padding-bottom: 30px;
}
.field {
  padding-top: 30px;
}
</style>

Это наша основная страница.

При загрузке будем запрашивать у бэкенда список вопросов, которые затем поместим в реактивную переменную formFields. Также при загрузке в словаре formValues создадим элементы с пустыми значениями и ключами, равными id вопросов.

Для генерации шаблона воспользуемся директивой v-for, с помощью которой будем итерироваться по значениям в ранее созданном списке formFields. И с помощью директивы v-if будем проверять значение поля component каждого вопроса и сравнивать его с названиями наших компонент, чтобы понять, для какого вопроса какой компонент использовать.

В каждый компонент будем передавать соответсвующие для их вопроса text и helpText.

Также будем принимать их emits и помещать полученные значения в словарь под нужным ключом.

Кнопку отправки будем блокировать, если есть незаполненные поля (длина массива errors > 0) или форма в процессе отправки.

Клик кнопки будет вызывать функцию onSubmit, которая перепарсит словарь ответов в список и отправит на бэкенд, затем обновит текст в хранилище infoStore и переведёт пользователя на страницу информации.

views/Info.vue
<script setup>
import {onMounted, ref} from 'vue'

import { useInfoStore } from '@/stores';
import { InfoContainer } from "@/components";

const text = ref("")

onMounted( () => {
    const infoStore = useInfoStore();
    if (!infoStore.text && localStorage.getItem('info_message')) {
      infoStore.setText(localStorage.getItem('info_message'));
    }
    text.value = infoStore.text
});
</script>

<template>
  <div class="image-container">
    <div class="bird2-container">
      <img src="@/assets/images/birds2.png">
    </div>
    <InfoContainer :text="text" />
    <div class="bird1-container">
      <img src="@/assets/images/birds.png">
    </div>
  </div>
</template>

<style scoped>
.image-container {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 100%;
  max-width: 800px;
  height: 600px;
  display: flex;
  justify-content: center;
  align-items: center;
  background: url("@/assets/images/full-sun.png") no-repeat center center;
  background-size: auto;
}
.bird1-container {
  position: absolute;
  right: 0;
  bottom: 0;
  margin-right: 20px;
}
.bird2-container {
  position: absolute;
  top: 0;
  left: 0;
  margin-left: 20px;
}
</style>

При загрузке этой страницы из хранилища infoStore будет доставаться текст и выводиться с помощью компонента InfoContainer в окружении картинок птиц.