feat: added new products_carousel bock type

This commit is contained in:
2025-11-13 13:41:35 +03:00
parent 6f9855995d
commit f0837e5c94
22 changed files with 747 additions and 108 deletions

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,
},
},
},
];

View 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;
}
},
}
});