Компоненты
В директории /components
создадим все необходимые для нас самостоятельные компоненты со своей логикой и стилями.
Самый базовый компонент, в котором будет рендериться основное содержимое страницы, указанное в роутере.
<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
.
<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 раз.
<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
передадим клик на кнопку, если она не отключена.
<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
будет выводить этот компонент поверх всего остального содержимого страницы, чтобы сымитировать поведение
загрузочного экрана.
<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
значений.
<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
.
<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
будем передавать значения в родительский компонент.
<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
укажем конкретный элемент этого словаря, соответствующий адаптеру.
И снова уже ранее описанным образом будем передавать наш реактивный словарь в родительский компонент.
<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
, которая будет хранить булевое значение, равное истине при отсутствии
выбранного значения.
На основе этой переменной будем менять класс выпадающего списка и добавлять сообщение об обязательности заполнения поля.
Помимо значения самого поля, будем в родительский компонент так же передавать и факт наличия ошибки.
<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, и в случае "Да" будем брать значение слайдера.
<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
можно поменять её состояние.
<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:
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');
}
},
});
В нём всего лишь будем хранить только одну переменную и объявим только две функции: на её обновление и удаление.