feat: added new products_carousel bock type
This commit is contained in:
52
frontend/admin/src/components/Form/CategoryLabel.vue
Normal file
52
frontend/admin/src/components/Form/CategoryLabel.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<span>
|
||||
<slot name="default" :value="selectedValue">
|
||||
{{ selectedValue?.label || 'Не выбрана' }}
|
||||
</slot>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useAutocompleteStore} from "@/stores/autocomplete.js";
|
||||
import {computed, onMounted, ref} from "vue";
|
||||
|
||||
const autocomplete = useAutocompleteStore();
|
||||
|
||||
const isLoading = ref(false);
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
function findNodeByKey(nodes, keyToFind) {
|
||||
for (const node of nodes) {
|
||||
if (node.key === keyToFind) {
|
||||
return node;
|
||||
}
|
||||
if (node.children?.length) {
|
||||
const found = findNodeByKey(node.children, keyToFind);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedValue = computed(() => {
|
||||
const id = props.id;
|
||||
if (!id) return null;
|
||||
|
||||
const node = findNodeByKey(autocomplete.categories || [], id);
|
||||
if (!node) return null;
|
||||
|
||||
return node;
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
isLoading.value = true;
|
||||
await autocomplete.fetchCategories();
|
||||
isLoading.value = false;
|
||||
});
|
||||
</script>
|
||||
50
frontend/admin/src/components/Form/CategorySelect.vue
Normal file
50
frontend/admin/src/components/Form/CategorySelect.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<TreeSelect
|
||||
:modelValue="selectedValue"
|
||||
@update:modelValue="setNewValue"
|
||||
filter
|
||||
filterMode="lenient"
|
||||
:options="autocomplete.categories"
|
||||
:disabled="disabled"
|
||||
:loading="isLoading"
|
||||
:placeholder="placeholder"
|
||||
class="md:w-80 w-full"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import TreeSelect from "primevue/treeselect";
|
||||
import {useAutocompleteStore} from "@/stores/autocomplete.js";
|
||||
import {computed, onMounted, ref} from "vue";
|
||||
|
||||
const autocomplete = useAutocompleteStore();
|
||||
|
||||
const isLoading = ref(false);
|
||||
const model = defineModel();
|
||||
|
||||
const props = defineProps({
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const selectedValue = computed(() => {
|
||||
const id = model.value;
|
||||
return id ? {[String(id)]: true} : null;
|
||||
});
|
||||
|
||||
function setNewValue(event) {
|
||||
model.value = Number(Object.keys(event)[0]);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
isLoading.value = true;
|
||||
await autocomplete.fetchCategories();
|
||||
isLoading.value = false;
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<BaseBlock
|
||||
:title="`Карусель товаров - ${value.title || 'Без заголовока'}`"
|
||||
@onRemove="$emit('onRemove')"
|
||||
@onShowSettings="$emit('onShowSettings')"
|
||||
>
|
||||
<div class="tw:mt-3 tw:text-sm tw:dark:text-slate-300 tw:space-y-1">
|
||||
<div>
|
||||
<span class="tw:font-bold tw:dark:text-slate-200">Описание:</span>
|
||||
{{ value.description }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="tw:font-bold tw:dark:text-slate-200 tw:mr-1">Категория:</span>
|
||||
<CategoryLabel :id="value.data.category_id"/>
|
||||
</div>
|
||||
</div>
|
||||
</BaseBlock>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import BaseBlock from "@/components/MainPageConfigurator/Blocks/BaseBlock.vue";
|
||||
import CategoryLabel from "@/components/Form/CategoryLabel.vue";
|
||||
|
||||
const emit = defineEmits(['onRemove', 'onShowSettings']);
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: Object,
|
||||
required: true,
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -3,6 +3,7 @@
|
||||
<TabList>
|
||||
<Tab value="0">Настройки блока</Tab>
|
||||
<Tab value="1">Основные настройки</Tab>
|
||||
<slot name="tabs"></slot>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel value="0">
|
||||
@@ -81,6 +82,7 @@
|
||||
<TabPanel value="1">
|
||||
<slot></slot>
|
||||
</TabPanel>
|
||||
<slot name="panels"></slot>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<div v-if="draft">
|
||||
<BaseForm
|
||||
v-model="draft"
|
||||
:isChanged="isChanged"
|
||||
@onApply="onApply"
|
||||
@cancel="$emit('cancel')"
|
||||
>
|
||||
<template #default>
|
||||
<pre>{{ draft }}</pre>
|
||||
<div class="tw:space-y-6">
|
||||
<Panel header="Основные настройки">
|
||||
<div class="tw:space-y-6">
|
||||
<!-- Категория -->
|
||||
<FormItem label="Категория">
|
||||
<template #default>
|
||||
<CategorySelect
|
||||
v-model="draft.data.category_id"
|
||||
placeholder="Выберите категорию"
|
||||
/>
|
||||
</template>
|
||||
<template #help>
|
||||
Категория из которой выводить товары для карусели.
|
||||
</template>
|
||||
</FormItem>
|
||||
|
||||
<!-- Текст кнопки просмотра категории -->
|
||||
<FormItem label="Текст на кнопке">
|
||||
<template #default>
|
||||
<InputText
|
||||
v-model="draft.data.all_text"
|
||||
placeholder="Смотреть всё"
|
||||
class="tw:w-full"
|
||||
/>
|
||||
</template>
|
||||
<template #help>
|
||||
Текст для кнопки, которая открывает просмотр товаров у категории
|
||||
</template>
|
||||
</FormItem>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel header="Настройки карусели">
|
||||
<div class="tw:space-y-6">
|
||||
<!-- Количество товаров в карусели -->
|
||||
<FormItem label="Количество товаров в карусели">
|
||||
<template #default>
|
||||
<InputNumber
|
||||
v-model="slidesPerView"
|
||||
:min="2"
|
||||
:max="5"
|
||||
:step="0.5"
|
||||
placeholder="2.5"
|
||||
:showButtons="true"
|
||||
/>
|
||||
<span class="tw:text-gray-600 tw:whitespace-nowrap">шт.</span>
|
||||
</template>
|
||||
|
||||
<template #help>
|
||||
Введите количество товаров, которые должны отображаться одновременно в карусели (от 2 до 5).
|
||||
Можно использовать дробные значения, чтобы часть следующего товара была видна.
|
||||
</template>
|
||||
</FormItem>
|
||||
|
||||
<!-- Расстояние между товарами -->
|
||||
<FormItem label="Расстояние между товарами">
|
||||
<template #default>
|
||||
<InputNumber
|
||||
v-model="spaceBetween"
|
||||
:min="0"
|
||||
:max="100"
|
||||
placeholder="20"
|
||||
:step="5"
|
||||
:showButtons="true"
|
||||
/>
|
||||
<span class="tw:text-gray-600 tw:whitespace-nowrap">px</span>
|
||||
</template>
|
||||
|
||||
<template #help>
|
||||
Задайте промежуток между товарами в карусели в пикселях, чтобы слайды не сливались и выглядели
|
||||
аккуратно.
|
||||
</template>
|
||||
</FormItem>
|
||||
|
||||
<!-- Режим Авто -->
|
||||
<FormItem label="Автоматическая прокрутка">
|
||||
<template #default>
|
||||
<ToggleSwitch
|
||||
v-model="isAutoplayEnabled"
|
||||
/>
|
||||
</template>
|
||||
<template #help>
|
||||
Включите автоматическую прокрутку карусели с заданной задержкой между переходами.
|
||||
</template>
|
||||
</FormItem>
|
||||
|
||||
<!-- Задержка -->
|
||||
<FormItem v-if="isAutoplayEnabled" label="Задержка">
|
||||
<template #default>
|
||||
<InputNumber
|
||||
v-model="autoplayDelay"
|
||||
:min="1000"
|
||||
:max="10000"
|
||||
placeholder="3000"
|
||||
:step="1000"
|
||||
:showButtons="true"
|
||||
/>
|
||||
<span class="tw:text-gray-600 tw:whitespace-nowrap">мс</span>
|
||||
</template>
|
||||
<template #help>
|
||||
Задержка между переходами в миллисекундах. Минимум 1000, максимум 10000.
|
||||
</template>
|
||||
</FormItem>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</template>
|
||||
</BaseForm>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {computed, defineExpose, onMounted, ref} from "vue";
|
||||
import {md5} from "js-md5";
|
||||
import BaseForm from "@/components/MainPageConfigurator/Forms/BaseForm.vue";
|
||||
import FormItem from "@/components/MainPageConfigurator/Forms/FormItem.vue";
|
||||
import CategorySelect from "@/components/Form/CategorySelect.vue";
|
||||
import {Fieldset, InputNumber, InputText, Panel, ToggleSwitch} from "primevue";
|
||||
|
||||
const draft = ref(null);
|
||||
const model = defineModel();
|
||||
const emit = defineEmits(['cancel']);
|
||||
|
||||
const isChanged = computed(() => md5(JSON.stringify(model.value)) !== md5(JSON.stringify(draft.value)));
|
||||
|
||||
// Инициализация carousel, если его нет (только для записи)
|
||||
function ensureCarousel() {
|
||||
if (!draft.value.data.carousel) {
|
||||
draft.value.data.carousel = {};
|
||||
}
|
||||
}
|
||||
|
||||
// Безопасное чтение значения из carousel
|
||||
function getCarouselValue(key, defaultValue) {
|
||||
return draft.value.data.carousel?.[key] ?? defaultValue;
|
||||
}
|
||||
|
||||
// Computed для управления slides_per_view
|
||||
const slidesPerView = computed({
|
||||
get() {
|
||||
return getCarouselValue('slides_per_view', 2.5);
|
||||
},
|
||||
set(value) {
|
||||
ensureCarousel();
|
||||
draft.value.data.carousel.slides_per_view = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Computed для управления space_between
|
||||
const spaceBetween = computed({
|
||||
get() {
|
||||
return getCarouselValue('space_between', 20);
|
||||
},
|
||||
set(value) {
|
||||
ensureCarousel();
|
||||
draft.value.data.carousel.space_between = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Computed для управления autoplay (включен/выключен)
|
||||
const isAutoplayEnabled = computed({
|
||||
get() {
|
||||
const autoplay = draft.value.data.carousel?.autoplay;
|
||||
return autoplay !== false && autoplay !== null && autoplay !== undefined;
|
||||
},
|
||||
set(value) {
|
||||
ensureCarousel();
|
||||
if (value) {
|
||||
// Если включаем, создаем объект с delay (используем текущее значение или 3000 по умолчанию)
|
||||
const currentDelay = draft.value.data.carousel.autoplay?.delay || 3000;
|
||||
draft.value.data.carousel.autoplay = { delay: currentDelay };
|
||||
} else {
|
||||
// Если выключаем, устанавливаем false
|
||||
draft.value.data.carousel.autoplay = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Computed для управления delay
|
||||
const autoplayDelay = computed({
|
||||
get() {
|
||||
const autoplay = draft.value.data.carousel?.autoplay;
|
||||
if (autoplay && typeof autoplay === 'object' && autoplay.delay) {
|
||||
return autoplay.delay;
|
||||
}
|
||||
return 3000; // Значение по умолчанию
|
||||
},
|
||||
set(value) {
|
||||
ensureCarousel();
|
||||
// Убеждаемся, что autoplay - это объект
|
||||
if (!draft.value.data.carousel.autoplay || draft.value.data.carousel.autoplay === false) {
|
||||
draft.value.data.carousel.autoplay = { delay: value };
|
||||
} else {
|
||||
draft.value.data.carousel.autoplay.delay = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function onApply() {
|
||||
model.value = JSON.parse(JSON.stringify(draft.value));
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
draft.value = JSON.parse(JSON.stringify(model.value));
|
||||
// Не создаем carousel здесь, чтобы не изменять draft при инициализации
|
||||
// carousel будет создан только при реальных изменениях пользователем
|
||||
});
|
||||
|
||||
defineExpose({isChanged});
|
||||
</script>
|
||||
@@ -4,17 +4,22 @@ import SliderForm from "@/components/MainPageConfigurator/Forms/SliderForm.vue";
|
||||
import CategoriesTopForm from "@/components/MainPageConfigurator/Forms/CategoriesTopForm.vue";
|
||||
import ProductsFeedBlock from "@/components/MainPageConfigurator/Blocks/ProductsFeedBlock.vue";
|
||||
import ProductsFeedForm from "@/components/MainPageConfigurator/Forms/ProductsFeedForm.vue";
|
||||
import ProductsCarouselBlock
|
||||
from "@/components/MainPageConfigurator/Blocks/ProductsCarouselBlock.vue";
|
||||
import ProductsCarouselForm from "@/components/MainPageConfigurator/Forms/ProductsCarouselForm.vue";
|
||||
|
||||
export const blockToComponentMap = {
|
||||
slider: SliderBlock,
|
||||
categories_top: CategoriesTopBlock,
|
||||
products_feed: ProductsFeedBlock,
|
||||
products_carousel: ProductsCarouselBlock,
|
||||
};
|
||||
|
||||
export const blockToFormMap = {
|
||||
slider: SliderForm,
|
||||
categories_top: CategoriesTopForm,
|
||||
products_feed: ProductsFeedForm,
|
||||
products_carousel: ProductsCarouselForm,
|
||||
};
|
||||
|
||||
export const blocks = [
|
||||
@@ -55,4 +60,20 @@ export const blocks = [
|
||||
max_page_count: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'products_carousel',
|
||||
title: 'Карусель товаров',
|
||||
description: 'Отображает товары в одну строку в виде прокручиваемой карусели.',
|
||||
is_enabled: true,
|
||||
goal_name: '',
|
||||
data: {
|
||||
category_id: null,
|
||||
all_text: null,
|
||||
carousel: {
|
||||
slides_per_view: null,
|
||||
space_between: null,
|
||||
autoplay: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
19
frontend/admin/src/stores/autocomplete.js
Normal file
19
frontend/admin/src/stores/autocomplete.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import {defineStore} from "pinia";
|
||||
import {apiGet} from "@/utils/http.js";
|
||||
|
||||
export const useAutocompleteStore = defineStore('autocomplete', {
|
||||
|
||||
state: () => ({
|
||||
categories: null,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
async fetchCategories() {
|
||||
if (this.categories !== null) return;
|
||||
const response = await apiGet('getAutocompleteCategories');
|
||||
if (response.success) {
|
||||
this.categories = response.data;
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user