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

Компоненты

В директории /components создадим все необходимые для нас самостоятельные компоненты со своей логикой и стилями.

components/View.vue
<template>
  <v-main>
    <router-view />
  </v-main>
</template>

Самый базовый компонент, в котором будет рендериться основное содержимое страницы, указанное в роутере.

components/Title.vue
<script setup>
import {useAuthStore} from "@/stores";

const props = defineProps({
  groupName: String,
  showGroupName: Boolean,
  showTitle: Boolean,
  showLogout: Boolean,
})

const logout = async () => {
  const authStore = useAuthStore();

  return await authStore.logout()
};
</script>

<template>
  <v-btn v-if="showLogout" prepend-icon="mdi-exit-to-app" variant="text" class="exit-button" @click="logout">Выйти</v-btn>
  <div v-if="showTitle" class="container">
    <img src="../assets/images/sun.png" class="sun-image">
    <div class="centered">
      <span class="main">
        Опрос
      </span>
      <span class="extra">
        по работе адаптеров
      </span>
      <span class="extra" v-if="groupName && showGroupName">
        группы {{ groupName }}
      </span>
    </div>
  </div>
</template>

<style scoped>
.container {
  position: relative;
  text-align: center;
}
.centered {
  position: absolute;
  width: 100%;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  height: 40%;
  min-height: 100px;
}
.sun-image {
  //width: 100%;
}
.main {
  display: block;
  color: #F1F1F1;
  font-size: 43px;
  font-family: 'Actay Wide';
  font-weight: 700;
  letter-spacing: 2.15px;
  word-wrap: break-word;
  margin-bottom: -10px;
  margin-top: -10px;
  text-shadow:
    0.7px 0.7px 0 #222222,
    -0.7px -0.7px 0 #222222,
    0.7px -0.7px 0 #222222,
    -0.7px 0.7px 0 #222222,
    0 2.5px 0 #222222;
}
.extra {
  display: block;
  color: #F1F1F1;
  font-size: 22px;
  font-family: 'Actay Wide';
  font-weight: 700;
  letter-spacing: 1.07px;
  word-wrap: break-word;
  text-shadow:
    0.7px 0.7px 0 #222222,
    -0.7px -0.7px 0 #222222,
    0.7px -0.7px 0 #222222,
    -0.7px 0.7px 0 #222222,
    0 2.5px 0 #222222;
}
.exit-button {
  z-index: 100;
  text-transform: none;
  font-family: "Actay Wide";
  position: absolute;
  right: 10px;
  top: 10px;
  border: none;
  font-size: 16px;
  padding: 10px;
  border-radius: 5px;
  display: flex;
}
</style>

Этот компонент будет выступать хедером страницы. Чтобы сделать его динамичным, объявим props с помощью функции defineProps, Значения для указанных переменных сможет передать родительский компонент.
Принимать будем четыре пропа: groupName, showGroupName, showTitle, showLogout.

Сам хедер состоит из двух элементов: текста на фоне картинки и кнопки выхода. В различных состояних текст хедера должен отличаться, кнопка выхода также должна появляться не всегда. Это поведение мы и будем варьировать на основе полученных пропов.

В этом нам поможет директива v-if, которая рендерит элементы только в том случае, если указанное условие истино.

Таким образом, если родительский класс передал showLogout=false, то кнопку выхода рендерить не будем. Таким же образом будем заголовок не будет отображаться, если showTitle=false. И если указан номер группы с истиным булевым, будем его выводить.

К кнопке выхода привяжем функцию хранилища logout.

components/Footer.vue
<script setup>

</script>

<template>
  <v-footer style="max-height: 91px; width: 100%; overflow: hidden; background: #64B5FF">
    <v-img max-height="91" min-height="91" min-width="375" max-width="375" src="@/assets/images/wave.png" v-for="n in 6" />
  </v-footer>
</template>

<style scoped>
.v-footer {
  margin: 0;
  padding: 0;
}
</style>

Футером нашей страницы будет картинка волны, которую мы с помощью директивы v-for повторим 6 раз.

components/LoginButton.vue
<script setup>
import { defineProps, defineEmits } from 'vue';

const props = defineProps({
  disabled: Boolean
});

const emits = defineEmits(['click']);
const handleClick = () => {
  if (!props.disabled) emits('click');
};
</script>
<template>
  <div class="button-container">
    <v-btn color="#F1F1F1" @click="handleClick" :disabled="props.disabled">
      Авторизоваться
    </v-btn>
    <p class="disclaimer">
      Опрос полностью анонимный, мы не собираем личную информацию (номер ИСУ, ФИО)
    </p>
  </div>
</template>
<style scoped>
.button-container {
  max-width: 450px;
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 20px;
}

.v-btn {
  width: 90%;
  text-transform: none;
  font-size: 30px;
  font-family: "Actay Wide";
  border-radius: 20px;
  display: inline-flex;
  justify-content: center;
  align-items: center;
  padding: 40px 50px;
  border: 3px solid #222222;
}
.v-btn:deep(.v-btn__overlay) {
  border-radius: 17px;
}

.disclaimer {
  width: 90%;
  font-family: "Actay Wide";
  font-size: 14px;
  text-align: center;
}
</style>

Этот компонент будет выводить кнопку авторизации вместе с небольшим дисклеймером. Помимо уже упомянутого props, в этом компоненте также укажем emits с помощью функции defineEmits. Переданные сюда события будут отправлены в родительский компонент, где смогут быть использованы.

В props укажем булевое disabled, с помощью которого будем отключать кнопку авторизации.
В emits передадим клик на кнопку, если она не отключена.

components/LoadingScreen.vue
<template>
  <div class="loading-screen">
    <img class="gif" src="@/assets/images/spinning_adapter.gif" alt="Загрузка..." />
    <p class="disclaimer">Обнуляем вашу стипендию...</p>
  </div>
</template>

<style>
.loading-screen {
  flex-direction: column;
  position: fixed;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  background: rgba(100, 181, 255, 0.8);
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 1000;
}
.gif {
  height: 200px;
}
.disclaimer {
  font-family: "Actay Wide";
  font-size: 20px;
  text-align: center;
}
</style>

Этот компонент будет выводить гифку вращающегося логотипа клуба вместе с небольшой надписью. С помощью css-свойства z-index будет выводить этот компонент поверх всего остального содержимого страницы, чтобы сымитировать поведение загрузочного экрана.

components/Label.vue
<script setup>
const props = defineProps({
  id: String,
  text: String,
  helpText: String,
})
</script>

<template>
  <label :for="id" class="form-label" style="display: inline-block;">
    <span style="font-size: larger;">{{ text }}</span>
    <br>
    <span style="font-size: smaller;">{{ helpText }}</span>
  </label>
</template>

<style scoped>
.form-label {
  font-family: "Actay Wide";
}
</style>

В этом компоненте будем создавать надписи для полей формы на основе переданных в props значений.

components/TextArea.vue
<script setup>
import { ref, watch } from 'vue';

import { Label } from "@/components";

let textArea = ref("");

const props = defineProps({
  id: String,
  text: String,
  helpText: String,
})

const emit = defineEmits(['update:textArea']);

watch(textArea, (newVal) => {
  emit('update:textArea', newVal)
})
</script>

<template>
  <Label :id="id" :text="text" :helpText="helpText" />
  <v-textarea
    class="my-textarea"
    bg-color="#F1F1F1"
    :no-resize=true
    rows="5"
    maxlength="1000"
    v-model="textArea"
  />
  <div class="char-count">{{ textArea.length }} / 1000</div>
</template>

<style scoped>
.my-textarea:deep(.v-field__input) {
  font-family: "Actay Wide";
}
.my-textarea:deep(.v-input__details) {
  border-top: 3px solid black;
}
.char-count {
  text-align: right;
  font-family: "Actay Wide";
  padding: 0;
  margin: 0;
}
</style>

Этот компонент импортирует другой компонент Label и выводит большое текстовое поле сразу с заголовком. Также он выводит небольшой счётчик символов.

Чтобы родительский компонент смог отслеживать изменение значения в этом поле, с помощью директивы v-model будем передавать все значения в переменную textArea, с помощью функции watch будем получать новое значение и отправлять его в родительский компонент с помощью emit.

components/Slider.vue
<script setup>
import {ref, watch, onMounted } from "vue"

import { Label } from "@/components";

const props = defineProps({
  id: String,
  text: String,
  helpText: String
})

const emit = defineEmits(['update:sliderValue'])

let sliderValue = ref(3)

watch(sliderValue, (newVal) => {
  emit('update:sliderValue', newVal)
})

onMounted(() => {
  emit('update:sliderValue', sliderValue.value)
})
</script>

<template>
  <Label :id="id" :text="text" :helpText="helpText" />
  <div class="slider-container">
    <span class="slider-number">1</span>
    <v-slider
      class="custom-slider"
      :id="id"
      track-color="#F1F1F1"
      :max="5" :min="1"
      :step="1"
      :thumb-label=true
      :hide-details=true
      v-model="sliderValue"
    />
    <span class="slider-number">5</span>
  </div>
</template>

<style scoped>
.slider-container {
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.custom-slider {
  padding: 0 1em;
}
.slider-number {
  font-family: "Actay Wide";
}
</style>

Этот компонент выводит слайдер от 1 до 5. С помощью ref создадим реактивную переменную со значением по умолчанию равным 3. С помощью директивы v-model создадим переменную sliderValue. В функции onMounted, которая вызывается при рендеринге компонента, будем передавать это значение по умолчанию в слайдер.

И таким же образом, как это сделано с компонентом TextArea будем передавать значения в родительский компонент.

components/AdapterSlider.vue
<script setup>
import { ref, watch, onMounted } from "vue"

import { Label } from "@/components";

const props = defineProps({
  id: String,
  text: String,
  helpText: String,
  adapters: Array
})

const emit = defineEmits(['update:sliderValue'])

let sliderValues = ref({});

onMounted(() => {
  sliderValues.value = props.adapters.reduce((accumulator, adapter) => {
    accumulator[adapter.id] = 3;
    return accumulator;
  }, {})
})

watch(sliderValues, (newVal) => {
  emit('update:sliderValue', newVal);
}, { deep: true });


</script>

<template>
  <Label :id="id" :text="text" :helpText="helpText" />
  <div class="adapters" v-for="adapter in props.adapters">
    <label :for="`id_${adapter.id}`" class="form-label" style="display: inline-block;">
      <span style="font-size: smaller;">{{ adapter.full_name }}</span>
    </label>
    <div class="slider-container">
      <span class="slider-number">1</span>
      <v-slider
        class="custom-slider"
        :id="`id_${adapter.id}`"
        track-color="#F1F1F1"
        :max="5" :min="1"
        :step="1"
        :thumb-label="true"
        :hide-details="true"
        v-model="sliderValues[adapter.id]"
      />
      <span class="slider-number">5</span>
    </div>
  </div>
</template>

<style scoped>
.adapters {
  padding-top: 10px;
}
.slider-container {
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.custom-slider {
  padding: 0 1em;
}
.slider-number {
  font-family: "Actay Wide";
}
.form-label {
  font-family: "Actay Wide";
}
</style>

Это усложнённая версия слайдера. На вход она должна принимать список словарей с id и фамилией и именем адаптеров, чтобы для каждого из них вывести отдельный слайдер со своим значением.

Создадим пустой реактивный словарь sliderValues и в функции onMounted заполним его значением по умолчанию равным 3 для каждого из адаптеров.

В директиве v-model укажем конкретный элемент этого словаря, соответствующий адаптеру.

И снова уже ранее описанным образом будем передавать наш реактивный словарь в родительский компонент.

components/Select.vue
<script setup>
import {ref, computed, watch} from 'vue';

import { Label } from "@/components";

const props = defineProps({
  id: String,
  text: String,
  helpText: String,
})

const emit = defineEmits(['selectedValue', 'error']);

let selectedValue = ref('');

watch(selectedValue, (newVal) => {
  emit('update:selectedValue', newVal)
})

const error = computed(() => !selectedValue.value);
watch(error, (newVal) => {
  emit('update:error', newVal);
});

</script>

<template>
  <Label :id="id" :text="text" :helpText="helpText" />
  <v-select
    v-model="selectedValue"
    :id="id"
    class="my-select"
    :class="{ 'has_error': error }"
    :items="['Да', 'Нет']"
    bg-color="#F1F1F1"
    :error="error"
    :error-messages="error ? 'Поле обязательно для заполнения!' : ''"
  />
</template>

<style scoped>
.my-select:deep(.v-input__details) {
  border-top: 3px solid black;
}
.has_error:deep(.v-input__details) {
  border-top: 3px solid red;
}
.my-select:deep(.v-select__selection-text) {
  font-family: "Actay Wide";
}
.my-select:deep(.v-overlay-container){
  font-family: "Actay Wide";
}
.my-select:deep(.v-messages__message) {
  font-family: "Actay Wide";
}
</style>

Этот компонент будет выводить выпадающий список с двумя выборами: "Да" и "Нет".

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

На основе этой переменной будем менять класс выпадающего списка и добавлять сообщение об обязательности заполнения поля.

Помимо значения самого поля, будем в родительский компонент так же передавать и факт наличия ошибки.

components/TrainingSelect.vue
<script setup>
import {computed, ref, watch} from 'vue';

import { Select, Slider } from "@/components";

const props = defineProps({
  id: String,
  text: String,
  helpText: String,
  trainingText: String,
  trainingHelpText: String
})

const emit = defineEmits(['update:selectedValue', 'update:error']);

let selectedValue = ref('');
watch(selectedValue, (newVal) => {
  let emitValue = newVal === "Нет" ? 0 : sliderValue.value;
  emit('update:selectedValue', emitValue);
})

let sliderValue = ref('')
watch(sliderValue, (newVal) => {
  if (selectedValue.value === "Да"){
    emit('update:selectedValue', newVal);
  }
})

const error = computed(() => !selectedValue.value);
watch(error, (newVal) => {
  emit('update:error', newVal);
});

</script>

<template>
  <Select
    :id="id"
    :text="text"
    :helpText="helpText"
    @update:selectedValue="val => { selectedValue = val }"
    @update:error="val => { error = val }"
  />

  <Slider
    v-if="selectedValue === 'Да'"
    :id="id"
    :text="trainingText"
    :helpText="trainingHelpText"
    @update:sliderValue="val => { sliderValue = val }"
  />
</template>

<style scoped>

</style>

Это поле использует компоненты Select и Slider, поэтому на вход требует не только text и helpText, но и trainingText и trainingHelpText для второго поля.

Это поле изначально выводит только выпадающий список, но если в нём выбрано значение "Да", то также выводит и слайдер.

Являясь родительским классом, этот компонент должен принимать значения этих компонент и передавать их уже в свой родительский класс.

Для удобства передавать будем численное значение, считая "Нет" за 0, и в случае "Да" будем брать значение слайдера.

components/SubmitButton.vue
<script setup>
const props = defineProps({
  disabled: Boolean,
})
</script>

<template>
  <v-btn color="#F1F1F1" type="submit" :disabled="disabled">
    Отправить
  </v-btn>
</template>

<style scoped>
.v-btn {
  text-transform: none;
  font-size: 15px;
  font-family: "Actay Wide";
  border-radius: 20px;
  border: 3px solid #222222;
}
</style>

Этот компонент создаёт кнопку для отправки формы.

Изначально кнопка отключена (предполагая, что в форме будут обязательные для заполнения поля), но с помощью props можно поменять её состояние.

components/InfoContainer.vue
<script setup>
const props = defineProps({
  text: String
})
</script>

<template>
  <div class="container">
    {{ text }}
  </div>
</template>

<style scoped>
.container {
  width: 300px;
  font-size: 20px;
  text-align: center;
  background: #F1F1F1;
  font-family: "Actay Wide";
  border-radius: 10px;
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  display: inline-flex;
  align-items: center;
  border: 2px solid black;
  padding: 20px;
}
</style>

Этот компонент выводит текст в оформленном контейнере. Он нужен, чтобы показывать различные информационные сообщения.

Чтобы удобно передавать в него текст между редиректами на страницы, хранить для него текст будет в отдельном хранилище Pinia:

stores/info.store.js
import { defineStore } from 'pinia';

export const useInfoStore = defineStore({
  id: 'info',
  state: () => ({
    text: ''
  }),
  actions: {
    setText(message) {
      this.text = message;
      localStorage.setItem('info_message', message);
    },
    removeText() {
      this.text = null;
      localStorage.removeItem('info_message');
    }
  },
});

В нём всего лишь будем хранить только одну переменную и объявим только две функции: на её обновление и удаление.