Squashed commit message
Some checks failed
Telegram Mini App Shop Builder / Compute version metadata (push) Has been cancelled
Telegram Mini App Shop Builder / Run Frontend tests (push) Has been cancelled
Telegram Mini App Shop Builder / Run Backend tests (push) Has been cancelled
Telegram Mini App Shop Builder / Run PHP_CodeSniffer (push) Has been cancelled
Telegram Mini App Shop Builder / Build module. (push) Has been cancelled
Telegram Mini App Shop Builder / release (push) Has been cancelled

This commit is contained in:
2026-03-11 22:08:41 +03:00
commit 5439e8ef9a
590 changed files with 65793 additions and 0 deletions

View File

@@ -0,0 +1,48 @@
<template>
<Dropdown
:model-value="modelValue"
:options="options"
option-label="label"
option-value="value"
placeholder="Интервал"
class="tw:w-[14rem] tw:shrink-0"
@update:model-value="$emit('update:modelValue', $event ?? '')"
/>
</template>
<script setup>
import { computed } from 'vue';
import Dropdown from 'primevue/dropdown';
const props = defineProps({
modelValue: {
type: String,
default: '',
},
});
defineEmits(['update:modelValue']);
/** Пресеты интервалов (label — для отображения, value — cron expression) */
const PRESETS = [
{ label: 'Раз в минуту', value: '* * * * *' },
{ label: 'Раз в 5 минут', value: '*/5 * * * *' },
{ label: 'Раз в 10 минут', value: '*/10 * * * *' },
{ label: 'Раз в час', value: '0 * * * *' },
{ label: 'Раз в 3 часа', value: '0 */3 * * *' },
{ label: 'Раз в 6 часов', value: '0 */6 * * *' },
{ label: 'Раз в сутки', value: '0 0 * * *' },
{ label: 'Раз в неделю', value: '0 0 * * 0' },
];
const presetValues = new Set(PRESETS.map((p) => p.value));
/** Только пресеты; если текущее значение не из списка — показываем его в списке (уже сохранённое в БД), чтобы не терять отображение */
const options = computed(() => {
const current = props.modelValue ?? '';
if (!current || presetValues.has(current)) {
return PRESETS;
}
return [{ label: current, value: current }, ...PRESETS];
});
</script>

View File

@@ -0,0 +1,104 @@
<template>
<SettingsItem label="URL для cron-job.org">
<template #default>
<InputGroup>
<Button
icon="fa fa-refresh"
severity="secondary"
:loading="regeneratingUrl"
v-tooltip.top="'Перегенерировать URL'"
@click="confirmRegenerateUrl"
/>
<Button icon="fa fa-copy" severity="secondary" @click="copyToClipboard"/>
<InputText readonly :model-value="cronJobOrgUrl" class="tw:w-full"/>
</InputGroup>
</template>
<template #help>
Создайте задачу на <a href="https://cron-job.org/" target="_blank" rel="noopener" class="tw:underline">cron-job.org</a>, укажите этот URL и интервал (например, каждые 5 минут). Метод: GET. Учитывайте лимиты по времени запроса на вашем хостинге для тяжёлых задач возможны таймауты. При утечке URL нажмите «Перегенерировать URL» и обновите задачу на cron-job.org.
</template>
</SettingsItem>
</template>
<script setup>
import { computed, ref } from 'vue';
import { useSettingsStore } from '@/stores/settings.js';
import SettingsItem from '@/components/SettingsItem.vue';
import InputText from 'primevue/inputtext';
import Button from 'primevue/button';
import InputGroup from 'primevue/inputgroup';
import { useConfirm } from 'primevue/useconfirm';
import { toastBus } from '@/utils/toastHelper.js';
import { apiPost } from '@/utils/http.js';
const settings = useSettingsStore();
const confirm = useConfirm();
const regeneratingUrl = ref(false);
const cronJobOrgUrl = computed(() => settings.items.cron?.schedule_url ?? '');
function confirmRegenerateUrl(event) {
confirm.require({
group: 'popup',
target: event.currentTarget,
message: 'После смены URL его нужно будет обновить в задаче на cron-job.org. Продолжить?',
icon: 'pi pi-exclamation-triangle',
rejectProps: { label: 'Отмена', severity: 'secondary', outlined: true },
acceptProps: { label: 'Перегенерировать', severity: 'secondary' },
accept: () => regenerateUrl(),
});
}
async function regenerateUrl() {
regeneratingUrl.value = true;
try {
const res = await apiPost('regenerateCronScheduleUrl', {});
if (res?.success && res.data?.api_key) {
settings.items.cron.api_key = res.data.api_key;
if (res.data.schedule_url !== undefined) {
settings.items.cron.schedule_url = res.data.schedule_url;
}
toastBus.emit('show', {
severity: 'success',
summary: 'URL обновлён',
detail: 'Обновите URL в задаче на cron-job.org',
life: 4000,
});
} else {
toastBus.emit('show', {
severity: 'error',
summary: 'Ошибка',
detail: res?.data?.error ?? 'Не удалось перегенерировать URL',
life: 4000,
});
}
} catch (err) {
toastBus.emit('show', {
severity: 'error',
summary: 'Ошибка',
detail: err?.response?.data?.error ?? 'Не удалось перегенерировать URL',
life: 4000,
});
} finally {
regeneratingUrl.value = false;
}
}
async function copyToClipboard() {
try {
await navigator.clipboard.writeText(cronJobOrgUrl.value);
toastBus.emit('show', {
severity: 'success',
summary: 'Скопировано',
detail: 'URL скопирован в буфер обмена',
life: 2000,
});
} catch (err) {
toastBus.emit('show', {
severity: 'error',
summary: 'Ошибка',
detail: 'Не удалось скопировать текст',
life: 2000,
});
}
}
</script>

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,40 @@
<template>
<Button
icon="fa fa-refresh"
severity="warn"
v-tooltip.top="'Сбросить кеш модуля'"
:loading="isLoading"
@click="resetCache"
/>
</template>
<script setup>
import {Button, useToast} from "primevue";
import {ref} from "vue";
import {apiPost} from "@/utils/http.js";
const isLoading = ref(false);
const toast = useToast();
async function resetCache() {
isLoading.value = true;
const response = await apiPost('resetCache');
if (response.success) {
toast.add({
severity: 'success',
summary: 'Выполнено',
detail: 'Кеш модуля сброшен.',
life: 3000
});
} else {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: 'Ошибка при сбросе кеша.',
life: 3000
});
}
isLoading.value = false;
}
</script>

View File

@@ -0,0 +1,70 @@
<template>
<div class="tw:flex-1 tw:flex tw:flex-col tw:gap-2 tw:h-full">
<Message v-if="isCustom" severity="info" class="tw:mb-2">
Вы находитесь в режиме ручного редактирования схемы.
<a
href="https://formkit.com/essentials/schema"
target="_blank"
title="Документация FormKit Schema"
class="tw:ml-1 tw:text-blue-600 hover:tw:underline"
>
Документация по схеме <i class="fa fa-external-link"></i>
</a>
</Message>
<Panel class="tw:flex-1 tw:flex tw:flex-col tw:overflow-hidden">
<template #header>
<div class="tw:flex tw:justify-between tw:items-center tw:w-full">
<div class="tw:flex tw:items-center tw:gap-2">
<span class="tw:font-medium">Редактор FormKit Schema</span>
</div>
<div v-if="error" class="tw:text-red-500 tw:text-sm">
<i class="fa fa-exclamation-circle"></i> {{ error }}
</div>
</div>
</template>
<div class="tw:flex-1 tw:h-full tw:overflow-hidden">
<Codemirror
:modelValue="modelValue"
@update:modelValue="onCodeChange"
placeholder="Code goes here..."
:style="{ height: '600px' }"
:autofocus="true"
:indent-with-tab="true"
:tab-size="2"
:extensions="extensions"
/>
</div>
</Panel>
</div>
</template>
<script setup>
import { Message, Panel } from 'primevue';
import { Codemirror } from 'vue-codemirror';
import { json } from '@codemirror/lang-json';
import { oneDark } from '@codemirror/theme-one-dark';
const props = defineProps({
modelValue: {
type: String,
default: ''
},
error: {
type: String,
default: null
},
isCustom: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['update:modelValue', 'change']);
const extensions = [json(), oneDark];
function onCodeChange(newVal) {
emit('update:modelValue', newVal);
emit('change', newVal);
}
</script>

View File

@@ -0,0 +1,376 @@
<template>
<div>
<div v-if="!selectedField" class="tw:text-gray-400 tw:text-center tw:py-8">
<i class="fa fa-mouse-pointer tw:text-2xl tw:mb-2"></i>
<p>Выберите поле для настройки</p>
</div>
<div v-else class="tw:space-y-4">
<!-- Тип поля (только для чтения) -->
<div>
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Тип поля</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs tw:mt-0.5"
v-tooltip.top="'Тип элемента формы (например, текст, число, выбор). Нельзя изменить после создания.'"
></i>
</div>
<InputText
:value="selectedField.$formkit"
disabled
class="tw:w-full tw:bg-gray-100"
/>
</div>
<!-- Название поля -->
<div>
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Название (name)</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs tw:mt-0.5"
v-tooltip.top="'Уникальный ключ поля в JSON-объекте данных. Используется при отправке формы. Должен быть на английском.'"
></i>
</div>
<InputText
:modelValue="selectedField.name"
@update:modelValue="onNameChange"
class="tw:w-full"
:class="{ 'p-invalid': nameError }"
placeholder="field_name"
:disabled="selectedField.locked"
/>
<small v-if="nameError" class="p-error tw:text-red-500 tw:text-xs tw:mt-1 tw:block">{{ nameError }}</small>
</div>
<!-- Метка -->
<div>
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Метка (label)</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs tw:mt-0.5"
v-tooltip.top="'Подпись, которая отображается над полем ввода для пользователя.'"
></i>
</div>
<InputText
:modelValue="selectedField.label"
@update:modelValue="updateField(selectedField.id, { label: $event })"
class="tw:w-full"
placeholder="Название поля"
/>
</div>
<!-- Help Text -->
<div>
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Подсказка (help)</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs tw:mt-0.5"
v-tooltip.top="'Дополнительный поясняющий текст, который отображается мелким шрифтом под полем.'"
></i>
</div>
<InputText
:modelValue="selectedField.help"
@update:modelValue="updateField(selectedField.id, { help: $event })"
class="tw:w-full"
placeholder="Текст подсказки"
/>
</div>
<!-- Иконки -->
<div class="tw:grid tw:grid-cols-2 tw:gap-2">
<div>
<div class="tw:flex tw:items-baseline tw:gap-2 tw:mb-1">
<label class="tw:block tw:text-sm tw:font-medium">Иконка слева</label>
</div>
<IconPicker
:modelValue="selectedField.prefixIcon"
@update:modelValue="updateField(selectedField.id, { prefixIcon: $event })"
/>
</div>
<div>
<div class="tw:flex tw:items-baseline tw:gap-2 tw:mb-1">
<label class="tw:block tw:text-sm tw:font-medium">Иконка справа</label>
</div>
<IconPicker
:modelValue="selectedField.suffixIcon"
@update:modelValue="updateField(selectedField.id, { suffixIcon: $event })"
/>
</div>
</div>
<!-- Placeholder (для текстовых полей) -->
<div v-if="hasPlaceholder">
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Текст-заполнитель (placeholder)</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs tw:mt-0.5"
v-tooltip.top="'Текст-подсказка внутри поля, который исчезает при начале ввода.'"
></i>
</div>
<InputText
:modelValue="selectedField.placeholder"
@update:modelValue="updateField(selectedField.id, { placeholder: $event })"
class="tw:w-full"
placeholder="Например: Введите ваше имя"
/>
</div>
<!-- Настройки для Range/Number -->
<div v-if="isRangeOrNumber">
<div class="tw:grid tw:grid-cols-2 tw:gap-2">
<div>
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Минимум</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs tw:mt-0.5"
v-tooltip.top="'Минимально допустимое значение.'"
></i>
</div>
<InputNumber
:modelValue="Number(selectedField.min)"
@update:modelValue="updateField(selectedField.id, { min: $event })"
class="tw:w-full"
inputClass="tw:w-full"
/>
</div>
<div>
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Максимум</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs tw:mt-0.5"
v-tooltip.top="'Максимально допустимое значение.'"
></i>
</div>
<InputNumber
:modelValue="Number(selectedField.max)"
@update:modelValue="updateField(selectedField.id, { max: $event })"
class="tw:w-full"
inputClass="tw:w-full"
/>
</div>
<div class="tw:col-span-2">
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Шаг (step)</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs tw:mt-0.5"
v-tooltip.top="'Шаг изменения значения (например, 1 или 0.5).'"
></i>
</div>
<InputNumber
:modelValue="Number(selectedField.step)"
@update:modelValue="updateField(selectedField.id, { step: $event })"
class="tw:w-full"
inputClass="tw:w-full"
/>
</div>
</div>
</div>
<!-- Настройки для Color -->
<div v-if="selectedField.$formkit === 'color'">
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Значение по умолчанию</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs"
v-tooltip.top="'Цвет, выбранный по умолчанию.'"
></i>
</div>
<div class="tw:flex tw:gap-2 tw:items-baseline">
<div class="tw:relative tw:w-10 tw:h-10 tw:rounded tw:overflow-hidden tw:border tw:border-gray-300">
<input
type="color"
:value="selectedField.value || '#000000'"
@input="updateField(selectedField.id, { value: $event.target.value })"
class="tw:absolute tw:-top-2 tw:-left-2 tw:w-16 tw:h-16 tw:cursor-pointer tw:p-0 tw:border-0"
/>
</div>
<InputText
:modelValue="selectedField.value"
@update:modelValue="updateField(selectedField.id, { value: $event })"
class="tw:flex-1"
placeholder="#000000"
/>
</div>
</div>
<!-- Опции (для select и radio) -->
<div v-if="hasOptions">
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Опции</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs tw:mt-0.5"
v-tooltip.top="'Список вариантов для выбора. Текст - то, что видит пользователь. Значение - то, что отправляется на сервер.'"
></i>
</div>
<div class="tw:space-y-2">
<div
v-for="(option, index) in selectedField.options"
:key="index"
class="tw:flex tw:gap-2 tw:items-center"
>
<InputText
:modelValue="option.label"
@update:modelValue="updateFieldOption(selectedField.id, index, 'label', $event)"
placeholder="Текст"
class="tw:flex-1 tw:w-full"
/>
<InputText
:modelValue="option.value"
@update:modelValue="updateFieldOption(selectedField.id, index, 'value', $event)"
placeholder="Значение"
class="tw:flex-1 tw:w-full"
/>
<Button
icon="fa fa-trash"
severity="danger"
size="small"
text
rounded
@click="removeFieldOption(selectedField.id, index)"
/>
</div>
<Button
label="Добавить опцию"
icon="fa fa-plus"
size="small"
class="tw:w-full"
@click="addFieldOption(selectedField.id)"
/>
</div>
</div>
<!-- Валидация -->
<div>
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Валидация</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs"
v-tooltip.top="'Правила проверки данных (FormKit Validation). Разделяются вертикальной чертой |. Например: required|email|length:5,10'"
></i>
</div>
<InputText
:modelValue="selectedField.validation"
@update:modelValue="updateField(selectedField.id, { validation: $event })"
class="tw:w-full"
placeholder="required|email|length:5,10"
/>
<p class="tw:text-xs tw:text-gray-500 tw:mt-1">
Примеры: required, email, length:5,10, number, url. <a href="https://formkit.com/essentials/validation" target="_blank">Документация <i class="fa fa-external-link"></i></a>
</p>
</div>
<!-- Label валидации -->
<div>
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Имя поля для ошибок</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs"
v-tooltip.top="'Название поля, которое будет подставляться в текст ошибки валидации вместо системного имени.'"
></i>
</div>
<InputText
:modelValue="selectedField.validationLabel"
@update:modelValue="updateField(selectedField.id, { validationLabel: $event })"
class="tw:w-full"
placeholder="Например: Пароль"
/>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue';
import { Button, InputText, InputNumber, useConfirm } from 'primevue';
import { useFormFields } from './composables/useFormFields.js';
import { supportsPlaceholder, supportsOptions } from './utils/fieldHelpers.js';
import IconPicker from '@/components/FormBuilder/IconPicker.vue';
const {
formFields,
selectedFieldId,
removeField,
updateField,
addFieldOption,
removeFieldOption,
updateFieldOption,
isFieldNameUnique,
setFieldError // Импортируем метод для установки ошибок
} = useFormFields();
const confirm = useConfirm();
const nameError = ref(null);
const selectedField = computed(() => {
if (!selectedFieldId || !selectedFieldId.value || !formFields || !formFields.value) {
return null;
}
return formFields.value.find(f => f.id === selectedFieldId.value);
});
const hasPlaceholder = computed(() => {
if (!selectedField.value) return false;
return supportsPlaceholder(selectedField.value.$formkit);
});
const hasOptions = computed(() => {
if (!selectedField.value) return false;
return supportsOptions(selectedField.value.$formkit);
});
const isRangeOrNumber = computed(() => {
if (!selectedField.value) return false;
return ['range', 'number'].includes(selectedField.value.$formkit);
});
// Сбрасываем ошибку при смене поля
watch(selectedFieldId, () => {
nameError.value = null;
// Ошибки в глобальном состоянии сбрасываются только при исправлении
});
function onNameChange(newName) {
if (!selectedField.value) return;
// Убираем пробелы и спецсимволы, кроме _ и букв/цифр
// Хотя FormKit позволяет многое, лучше придерживаться стандартных правил переменных
const sanitized = newName.trim(); // .replace(/[^a-zA-Z0-9_]/g, '');
// Не будем жестко фильтровать, но проверим уникальность
if (!sanitized) {
nameError.value = 'Имя поля не может быть пустым';
setFieldError(selectedField.value.id, nameError.value);
return;
}
if (sanitized !== selectedField.value.name && !isFieldNameUnique(sanitized, selectedField.value.id)) {
nameError.value = 'Поле с таким именем уже существует';
setFieldError(selectedField.value.id, nameError.value);
return;
}
nameError.value = null;
setFieldError(selectedField.value.id, null);
updateField(selectedField.value.id, { name: sanitized });
}
function removeSelectedField(event) {
if (!selectedField.value) return;
confirm.require({
target: event.currentTarget,
message: `Вы уверены, что хотите удалить поле "${selectedField.value.label || selectedField.value.name}"?`,
icon: 'fa fa-exclamation-triangle',
acceptLabel: 'Да, удалить',
rejectLabel: 'Нет',
acceptClass: 'p-button-danger p-button-sm',
rejectClass: 'p-button-secondary p-button-sm p-button-text',
accept: () => {
removeField(selectedField.value.id);
}
});
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,54 @@
<template>
<div class="tw:h-full tw:min-h-0 tw:flex tw:flex-col tw:bg-white tw:border tw:border-gray-200 tw:rounded-lg tw:overflow-hidden">
<!-- Заголовок -->
<div class="tw:p-4 tw:bg-[#f8f9fa] tw:border-b tw:border-gray-200 tw:font-bold tw:text-[#374151] tw:flex-shrink-0">
Доступные поля
</div>
<!-- Контент со скроллом -->
<div class="tw:flex-1 tw:min-h-0 tw:overflow-y-auto tw:p-4">
<draggable
:list="availableFields"
:group="{ name: 'fields', pull: 'clone', put: false }"
:sort="false"
:clone="cloneField"
item-key="type"
class="tw:space-y-2"
>
<template #item="{ element: field }">
<div
class="tw:p-3 tw:bg-gray-50 tw:border tw:border-gray-200 tw:rounded tw:cursor-move tw:hover:bg-gray-100 tw:transition-colors"
>
<div class="tw:flex tw:items-center tw:gap-2">
<i :class="field.icon" class="tw:text-gray-600"></i>
<span class="tw:text-sm tw:font-medium">{{ field.label }}</span>
</div>
</div>
</template>
</draggable>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import draggable from 'vuedraggable';
import { AVAILABLE_FIELDS } from './constants/availableFields.js';
import { useFormFields } from './composables/useFormFields.js';
const availableFields = ref(AVAILABLE_FIELDS);
const { generateFieldId } = useFormFields();
// Функция клонирования элемента при перетаскивании в канвас
function cloneField(field) {
const id = generateFieldId();
return {
id: id,
...field.defaultConfig,
name: field.defaultConfig.name || `field_${id.split('_')[1]}`,
};
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,476 @@
<template>
<div class="tw:flex tw:flex-col tw:h-[calc(100vh-200px)] tw:gap-4">
<!-- Popup подтверждения очистки -->
<ConfirmPopup group="clearForm" />
<!-- Диалог предупреждения при смене режима -->
<ConfirmDialog group="modeSwitch">
<template #message="{ message }">
<div class="tw:whitespace-pre-wrap tw:max-w-lg">{{ message.message }}</div>
</template>
</ConfirmDialog>
<!-- Панель инструментов -->
<div class="tw:flex tw:justify-start tw:items-center tw:pb-2 tw:border-b tw:border-gray-200">
<!-- Переключатель режимов -->
<SelectButton
:key="selectButtonKey"
:modelValue="activeMode"
:options="modes"
optionLabel="label"
optionValue="value"
:allowEmpty="false"
@update:modelValue="handleModeChange"
>
<template #option="slotProps">
<i :class="slotProps.option.icon" class="tw:mr-2"></i>
<span>{{ slotProps.option.label }}</span>
</template>
</SelectButton>
</div>
<div class="tw:flex tw:flex-1 tw:gap-4 tw:overflow-hidden tw:min-h-0">
<!-- Режим визуального конструктора -->
<template v-if="activeMode === 'visual'">
<!-- Если форма кастомная, показываем предупреждение вместо редактора -->
<div v-if="isCustom" class="tw:flex-1 tw:flex tw:items-center tw:justify-center">
<div class="tw:max-w-2xl tw:p-8 tw:bg-yellow-50 tw:border-2 tw:border-yellow-200 tw:rounded-lg">
<div class="tw:flex tw:items-start tw:gap-4">
<i class="fa fa-exclamation-triangle tw:text-3xl tw:text-yellow-600"></i>
<div>
<h3 class="tw:text-lg tw:font-bold tw:text-yellow-800 tw:mb-2">
Форма является кастомной
</h3>
<p class="tw:text-yellow-700 tw:mb-4">
Эта форма была создана или изменена вручную в редакторе кода и не может быть отображена в визуальном редакторе.
</p>
<p class="tw:text-yellow-700 tw:mb-4">
Для работы с этой формой используйте режим "Код". Если вы хотите создать новую форму в визуальном редакторе, необходимо сбросить текущую форму.
</p>
<Button
label="Перейти в режим кода"
icon="fa fa-code"
@click="activeMode = 'code'"
class="tw:mr-2"
/>
<Button
label="Сбросить и создать новую"
icon="fa fa-trash"
severity="danger"
outlined
@click="showResetConfirmation"
/>
</div>
</div>
</div>
</div>
<!-- Обычный визуальный редактор для некстомных форм -->
<template v-else>
<!-- Панель доступных полей -->
<div class="tw:w-64 tw:flex-shrink-0 tw:h-full tw:overflow-hidden">
<FieldsPanel class="tw:h-full" />
</div>
<!-- Основная зона конструктора -->
<div class="tw:flex-1 tw:flex tw:gap-4 tw:overflow-hidden">
<!-- Зона формы -->
<div class="tw:flex-1 tw:flex tw:flex-col tw:border tw:border-gray-200 tw:rounded-lg tw:overflow-hidden tw:bg-white tw:relative">
<!-- Кнопка очистки (абсолютно позиционирована) -->
<div class="tw:absolute tw:top-4 tw:right-4 tw:z-20">
<Button
label="Очистить"
icon="fa fa-trash"
severity="danger"
size="small"
text
v-tooltip.left="'Удалить все поля и очистить форму'"
@click="clearForm"
/>
</div>
<!-- Контент (FormCanvas) занимает все оставшееся пространство -->
<div class="tw:flex-1 tw:overflow-y-auto tw:relative">
<FormCanvas class="tw:min-h-full" />
</div>
</div>
<!-- Панель настроек (справа) -->
<div class="tw:w-80 tw:flex-shrink-0 tw:h-full tw:overflow-y-auto">
<Panel class="tw:min-h-full">
<template #header>
<span>Настройки поля</span>
</template>
<FieldSettings />
</Panel>
</div>
</div>
</template>
</template>
<!-- Режим редактирования кода -->
<template v-if="activeMode === 'code'">
<CodeEditor
v-model="jsonCode"
:error="jsonError"
:is-custom="isCustom"
@change="handleJsonInput"
/>
</template>
<!-- Режим предпросмотра -->
<template v-if="activeMode === 'preview'">
<div class="tw:flex-1 tw:flex tw:justify-center tw:overflow-auto">
<div class="tw:w-full">
<div v-if="jsonError" class="tw:p-4 tw:bg-red-50 tw:border tw:border-red-200 tw:rounded tw:text-red-700">
<i class="fa fa-exclamation-circle tw:mr-2"></i>
Ошибка в схеме: {{ jsonError }}
</div>
<FormRenderer
v-else
:schema="formSchema"
submit-label="Отправить форму"
@submit="handleFormSubmit"
/>
</div>
</div>
</template>
</div>
</div>
</template>
<script setup>
import { ref, provide, computed, onMounted, watch } from 'vue';
import { Button, Panel, SelectButton, useConfirm, ConfirmPopup, ConfirmDialog } from 'primevue';
import FieldsPanel from '@/components/FormBuilder/FieldsPanel.vue';
import FormCanvas from '@/components/FormBuilder/FormCanvas.vue';
import FieldSettings from '@/components/FormBuilder/FieldSettings.vue';
import FormRenderer from '@/components/FormBuilder/FormRenderer.vue';
import CodeEditor from '@/components/FormBuilder/CodeEditor.vue';
import { toastBus } from '@/utils/toastHelper';
import { saveRevision } from './utils/revisionManager.js';
import { createEmptySchema } from './utils/schemaParser.js';
const formFields = defineModel({
type: Array,
default: () => []
});
const isCustom = defineModel('isCustom', {
type: Boolean,
default: false
});
// Локальные состояния (не сохраняются в БД)
const dirtyFromCode = ref(false);
const lastSyncedSchema = ref(null);
const activeMode = ref('visual');
const jsonCode = ref('');
const jsonError = ref(null);
const confirm = useConfirm();
const selectButtonKey = ref(0);
// Алиас формы для сохранения ревизий (можно передавать через props, пока используем 'checkout')
const formAlias = 'checkout';
const modes = [
{ label: 'Визуальный', value: 'visual', icon: 'fa fa-th-large' },
{ label: 'Код', value: 'code', icon: 'fa fa-code' },
{ label: 'Предпросмотр', value: 'preview', icon: 'fa fa-eye' },
];
// Состояние выбранного поля
const selectedFieldId = ref(null);
// Состояние ошибок полей
const fieldErrors = ref({});
// Предоставляем состояние дочерним компонентам
provide('formFields', formFields);
provide('selectedFieldId', selectedFieldId);
provide('fieldErrors', fieldErrors);
// Схема формы для предпросмотра
const formSchema = computed(() => {
return formFields.value || [];
});
// Инициализация при монтировании
onMounted(() => {
initializeForm();
});
// Инициализация формы при загрузке из БД
function initializeForm() {
const schema = formFields.value || [];
// Устанавливаем lastSyncedSchema равным загруженной схеме
lastSyncedSchema.value = JSON.parse(JSON.stringify(schema));
// dirtyFromCode всегда false при загрузке
dirtyFromCode.value = false;
// Определяем начальный режим на основе isCustom
if (isCustom.value) {
activeMode.value = 'code';
jsonCode.value = JSON.stringify(schema, null, 2);
} else {
activeMode.value = 'visual';
jsonCode.value = JSON.stringify(schema, null, 2);
}
}
// Отслеживание изменений в визуальном редакторе
watch(formFields, (newSchema) => {
// Если мы в визуальном режиме и форма не кастомная, обновляем lastSyncedSchema
if (activeMode.value === 'visual' && !dirtyFromCode.value && !isCustom.value) {
lastSyncedSchema.value = JSON.parse(JSON.stringify(newSchema));
// Обновляем jsonCode для синхронизации
jsonCode.value = JSON.stringify(newSchema, null, 2);
}
}, { deep: true });
// Отслеживание изменений isCustom для переинициализации
watch(isCustom, () => {
// При изменении isCustom извне (например, при загрузке из БД) переинициализируем
if (activeMode.value === 'code' && !isCustom.value) {
// Если isCustom стал false, но мы в режиме кода, это значит форма была сброшена
// Синхронизируем состояния
lastSyncedSchema.value = JSON.parse(JSON.stringify(formFields.value));
dirtyFromCode.value = false;
}
});
function hasDuplicateNames(fields) {
if (!Array.isArray(fields)) return false;
const names = new Set();
for (const field of fields) {
if (field.name) {
if (names.has(field.name)) {
return true;
}
names.add(field.name);
}
}
return false;
}
function cancelModeSwitch() {
// Инкементируем ключ, чтобы принудительно перерисовать SelectButton с текущим activeMode
selectButtonKey.value++;
}
function handleModeChange(newMode) {
// Если пытаемся переключиться на тот же режим
if (newMode === activeMode.value) return;
// Если переключаемся ИЗ режима кода
if (activeMode.value === 'code') {
// Пытаемся распарсить JSON перед уходом
if (jsonError.value) {
toastBus.emit('show', { severity: 'error', summary: 'Ошибка JSON', detail: 'Исправьте ошибки в JSON перед переключением режима' });
cancelModeSwitch();
return;
}
try {
const parsed = JSON.parse(jsonCode.value);
if (hasDuplicateNames(parsed)) {
toastBus.emit('show', { severity: 'error', summary: 'Ошибка валидации', detail: 'В схеме есть поля с одинаковыми именами (name). Исправьте их перед переключением.' });
cancelModeSwitch();
return;
}
// Если переключаемся в визуальный режим
if (newMode === 'visual') {
// Проверяем, нужно ли показывать предупреждение
const needsWarning = isCustom.value || dirtyFromCode.value;
if (needsWarning) {
// Сохраняем ревизию перед деструктивной операцией
saveRevision(formAlias, formFields.value, 'reset_to_visual');
// Показываем предупреждение
confirm.require({
group: 'modeSwitch',
header: 'Предупреждение',
message: isCustom.value
? 'Загруженная форма является кастомной и может содержать неподдерживаемые элементы.\n\nОткрытие визуального редактора приведет к полному сбросу формы. Все нестандартные настройки будут потеряны.'
: 'Форма была изменена вручную в редакторе кода.\n\nВизуальный редактор не поддерживает все конструкции, и для его открытия потребуется полностью сбросить форму и создать новую. Все нестандартные настройки будут потеряны.',
icon: 'fa fa-exclamation-triangle',
acceptLabel: 'Сбросить и открыть визуальный редактор',
rejectLabel: 'Отменить',
acceptClass: 'p-button-danger',
accept: () => {
resetFormToVisual();
activeMode.value = 'visual';
},
reject: () => {
cancelModeSwitch();
}
});
return;
} else {
// Если isCustom=false и dirtyFromCode=false, просто переключаемся
// Обновляем схему из кода
formFields.value = parsed;
lastSyncedSchema.value = JSON.parse(JSON.stringify(parsed));
dirtyFromCode.value = false;
}
} else if (newMode === 'preview') {
// Переход в Preview - обновляем модель из кода
formFields.value = parsed;
}
} catch (e) {
console.error('Ошибка парсинга при переключении:', e);
toastBus.emit('show', { severity: 'error', summary: 'Ошибка', detail: 'Некорректный JSON' });
cancelModeSwitch();
return;
}
}
// Если переключаемся В режим кода
if (newMode === 'code') {
// Обновляем jsonCode из текущей схемы
jsonCode.value = JSON.stringify(formFields.value, null, 2);
// Обновляем lastSyncedSchema, если мы пришли из визуального редактора
if (activeMode.value === 'visual') {
lastSyncedSchema.value = JSON.parse(JSON.stringify(formFields.value));
dirtyFromCode.value = false;
}
// isCustom не меняем автоматически при переходе в режим кода
// Он установится в true только когда пользователь реально изменит код (через handleJsonInput)
}
// Если переключаемся В визуальный режим из preview
if (newMode === 'visual' && activeMode.value === 'preview') {
// Никаких проверок, просто переключаемся
}
// Обновляем режим для успешных переходов
activeMode.value = newMode;
}
/**
* Сбрасывает форму для визуального редактора
*/
function resetFormToVisual() {
// Сохраняем ревизию (уже сохранена выше, но на всякий случай)
saveRevision(formAlias, formFields.value, 'reset_to_visual');
// Создаем пустую схему
const emptySchema = createEmptySchema();
// Обновляем все состояния
formFields.value = emptySchema;
selectedFieldId.value = null;
isCustom.value = false;
jsonCode.value = JSON.stringify(emptySchema, null, 2);
lastSyncedSchema.value = JSON.parse(JSON.stringify(emptySchema));
dirtyFromCode.value = false;
}
/**
* Показывает подтверждение сброса формы
*/
function showResetConfirmation() {
// Сохраняем ревизию перед деструктивной операцией
saveRevision(formAlias, formFields.value, 'reset_to_visual');
confirm.require({
group: 'modeSwitch',
header: 'Подтверждение сброса',
message: 'Вы уверены, что хотите сбросить кастомную форму и создать новую в визуальном редакторе?\n\nВсе текущие настройки будут потеряны.',
icon: 'fa fa-exclamation-triangle',
acceptLabel: 'Сбросить и создать новую',
rejectLabel: 'Отменить',
acceptClass: 'p-button-danger',
accept: () => {
resetFormToVisual();
activeMode.value = 'visual';
},
reject: () => {
// Отменяем действие
}
});
}
function handleJsonInput() {
try {
const parsed = JSON.parse(jsonCode.value);
if (hasDuplicateNames(parsed)) {
jsonError.value = 'Ошибка: найдены поля с одинаковыми именами (name)';
} else {
jsonError.value = null;
}
// Обновляем модель сразу, чтобы изменения не терялись
formFields.value = parsed;
// Проверяем, изменилась ли схема относительно lastSyncedSchema
if (lastSyncedSchema.value !== null) {
const currentSchemaStr = JSON.stringify(parsed);
const lastSyncedStr = JSON.stringify(lastSyncedSchema.value);
const hasChanges = currentSchemaStr !== lastSyncedStr;
dirtyFromCode.value = hasChanges;
// Если есть изменения и форма еще не кастомная, устанавливаем isCustom=true
if (hasChanges && !isCustom.value) {
isCustom.value = true;
}
} else {
// Если lastSyncedSchema еще не установлен, считаем что изменения есть
dirtyFromCode.value = true;
if (!isCustom.value) {
isCustom.value = true;
}
}
} catch (e) {
jsonError.value = e.message;
// При ошибке парсинга не обновляем dirtyFromCode и isCustom
}
}
function clearForm(event) {
confirm.require({
target: event.currentTarget,
group: 'clearForm',
message: 'Вы уверены, что хотите очистить форму? Все поля будут удалены.',
icon: 'fa fa-exclamation-triangle',
acceptLabel: 'Да, очистить',
rejectLabel: 'Нет',
acceptClass: 'p-button-danger p-button-sm',
rejectClass: 'p-button-secondary p-button-sm p-button-text',
accept: () => {
// Сохраняем ревизию перед очисткой
saveRevision(formAlias, formFields.value, 'clear_form');
const emptySchema = createEmptySchema();
formFields.value = emptySchema;
selectedFieldId.value = null;
jsonCode.value = JSON.stringify(emptySchema, null, 2);
lastSyncedSchema.value = JSON.parse(JSON.stringify(emptySchema));
dirtyFromCode.value = false;
// После очистки в визуальном редакторе форма не кастомная
isCustom.value = false;
toastBus.emit('show', { severity: 'success', summary: 'Успешно', detail: 'Форма очищена' });
}
});
}
function handleFormSubmit(data) {
console.log('Данные формы:', data);
toastBus.emit('show', { severity: 'success', summary: 'Форма отправлена', detail: 'Данные: ' + JSON.stringify(data, null, 2) });
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,144 @@
<template>
<div
class="tw:min-h-full tw:w-full tw:flex tw:flex-col tw:items-center tw:p-8 blueprint-bg"
@click.self="handleBackgroundClick"
>
<div v-if="formFields.length === 0" class="tw:absolute tw:top-1/2 tw:left-1/2 tw:-translate-x-1/2 tw:-translate-y-1/2 tw:z-0 tw:text-center tw:text-gray-500 tw:py-12 tw:bg-white/80 tw:backdrop-blur-sm tw:rounded-xl tw:p-8 tw:shadow-sm tw:max-w-md tw:pointer-events-none">
<i class="fa fa-mouse-pointer tw:text-4xl tw:mb-4 tw:text-blue-500/50"></i>
<p class="tw:font-medium">Перетащите поля сюда, чтобы начать создавать форму</p>
</div>
<draggable
v-model="formFields"
group="fields"
item-key="id"
class="tw:w-full tw:max-w-2xl tw:space-y-4 tw:flex-1 tw:min-h-[300px] tw:relative tw:z-10 tw:pb-24"
ghost-class="ghost-field"
drag-class="drag-field"
@change="handleDragChange"
@click.self="handleBackgroundClick"
>
<template #item="{ element: field, index }">
<div
class="tw:relative tw:group tw:border-2 tw:rounded-lg tw:p-4 tw:bg-white tw:shadow-sm tw:cursor-pointer tw:transition-all"
:class="[
fieldErrors[field.id] ? 'tw:border-red-500 tw:ring-2 tw:ring-red-200' :
selectedFieldId === field.id ? 'tw:border-blue-500 tw:ring-2 tw:ring-blue-200' : 'tw:border-transparent hover:tw:border-blue-400'
]"
@click.stop="selectField(field.id)"
>
<!-- Иконка ошибки -->
<div
v-if="fieldErrors[field.id]"
class="tw:absolute tw:-left-3 tw:-top-3 tw:z-20 tw:bg-red-500 tw:text-white tw:rounded-full tw:w-6 tw:h-6 tw:flex tw:items-center tw:justify-center tw:shadow-sm"
v-tooltip.top="fieldErrors[field.id]"
>
<i class="fa fa-exclamation tw:text-xs"></i>
</div>
<!-- Кнопка удаления (справа за пределами блока, видна при выборе) -->
<div
class="tw:absolute tw:-right-12 tw:top-1/2 tw:-translate-y-1/2 tw:z-10 tw:transition-opacity tw:duration-200"
:class="selectedFieldId === field.id ? 'tw:opacity-100' : 'tw:opacity-0 tw:pointer-events-none'"
>
<Button
@click.stop="removeField(field.id)"
icon="fa fa-trash"
severity="danger"
rounded
size="small"
v-tooltip.right="'Удалить поле'"
class="!tw:shadow-md !tw:w-9 !tw:h-9 !tw:p-0 tw:flex tw:items-center tw:justify-center hover:!tw:bg-red-600"
/>
</div>
<!-- Оверлей для перехвата кликов поверх disabled инпутов -->
<div class="tw:absolute tw:inset-0 tw:z-[1] tw:bg-transparent"></div>
<!-- Предпросмотр поля -->
<div class="tw:relative tw:z-0">
<FormKit
v-if="field.$formkit"
:key="`${field.id}-${field.prefixIcon}-${field.suffixIcon}`"
:type="field.$formkit"
v-bind="getFieldProps(field)"
:name="field.name || `field_${field.id}`"
/>
<div v-else class="tw:text-red-500 tw:text-sm">
<i class="fa fa-exclamation-triangle tw:mr-2"></i>
Поле без типа $formkit
</div>
</div>
</div>
</template>
</draggable>
</div>
</template>
<script setup>
import { FormKit } from '@formkit/vue';
import { Button } from 'primevue';
import draggable from 'vuedraggable';
import { useFormFields } from './composables/useFormFields.js';
import { getFieldProps } from './utils/fieldHelpers.js';
import { toastBus } from '@/utils/toastHelper';
// Используем composable для работы с полями
const {
formFields,
selectedFieldId,
fieldErrors,
selectField,
removeField,
isFieldNameUnique
} = useFormFields();
function handleDragChange(evt) {
if (evt.added) {
const addedField = evt.added.element;
// Проверяем уникальность имени
if (!isFieldNameUnique(addedField.name, addedField.id)) {
// Удаляем дубликат
removeField(addedField.id);
toastBus.emit('show', {
severity: 'error',
summary: 'Ошибка добавления',
detail: `Поле с именем "${addedField.name}" уже добавлено в форму.`
});
return;
}
selectField(addedField.id);
}
}
function handleBackgroundClick() {
// Сбрасываем выбор, если есть выбранный элемент
if (selectedFieldId.value) {
selectedFieldId.value = null;
}
}
</script>
<style scoped>
.blueprint-bg {
background-color: #e2e8f0;
background-image: radial-gradient(#cbd5e1 1px, transparent 1px);
background-size: 10px 10px;
}
.ghost-field {
opacity: 0.5;
background-color: #eff6ff;
border-color: #93c5fd;
border-style: dashed;
}
.drag-field {
opacity: 1;
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
transform: scale(1.05);
}
</style>

View File

@@ -0,0 +1,57 @@
<template>
<div class="tw:flex tw:justify-center tw:items-start tw:p-8">
<!-- Phone Mockup -->
<div class="tw:relative tw:inline-grid tw:justify-items-center tw:bg-black tw:border-[2.5px] tw:border-gray-600 tw:rounded-[32.5px] tw:p-[3px] tw:overflow-hidden tw:w-full tw:max-w-[280px]" style="aspect-ratio: 462/978;">
<!-- Camera -->
<div class="tw:absolute tw:top-[3%] tw:left-1/2 tw:-translate-x-1/2 tw:z-10 tw:bg-black tw:rounded-[8.5px] tw:w-[28%] tw:h-[3.7%]"></div>
<!-- Display -->
<div class="tw:relative tw:rounded-[27px] tw:w-full tw:h-full tw:overflow-hidden tw:bg-gray-100">
<div class="tw:pt-15 tw:px-2 tw:pb-2 tw:overflow-y-auto tw:max-h-full tw:h-full">
<FormKit
type="form"
@submit="handleSubmit"
:submit-label="submitLabel"
outer-class="tw:space-y-4"
v-model="form"
>
<FormKitSchema :schema="schema" :data="data"/>
</FormKit>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { FormKit, FormKitSchema } from '@formkit/vue';
import {reactive, ref} from "vue";
const form = ref({});
const data = reactive(form);
const props = defineProps({
schema: {
type: Array,
required: true,
default: () => [],
},
submitLabel: {
type: String,
default: 'Отправить',
},
});
const emit = defineEmits(['submit']);
function handleSubmit(data) {
emit('submit', data);
}
</script>
<style scoped>
::v-deep(ul.formkit-messages) {
margin-bottom: 0;
padding-left: 0;
}
</style>

View File

@@ -0,0 +1,124 @@
<template>
<div>
<div class="tw:flex tw:gap-2">
<div
class="tw:flex-1 tw:border tw:border-gray-300 tw:rounded-md tw:p-2 tw:flex tw:items-center tw:gap-2 tw:cursor-pointer hover:tw:bg-gray-50 tw:min-h-[42px]"
@click="visible = true"
>
<div v-if="modelValue" class="tw:w-5 tw:h-5 tw:text-gray-600 tw:flex tw:items-center tw:justify-center" v-html="getIconSvg(modelValue)"></div>
<span v-if="modelValue" class="tw:text-sm tw:text-gray-700">{{ modelValue }}</span>
<span v-else class="tw:text-sm tw:text-gray-400">Выберите иконку...</span>
</div>
<Button
v-if="modelValue"
icon="fa fa-times"
text
rounded
severity="secondary"
@click="emit('update:modelValue', null)"
v-tooltip="'Очистить'"
/>
</div>
<Dialog
v-model:visible="visible"
modal
header="Выберите иконку"
:style="{ width: '50vw', maxWidth: '600px' }"
:breakpoints="{ '960px': '75vw', '640px': '90vw' }"
>
<div class="tw:flex tw:flex-col tw:gap-4">
<IconField>
<InputIcon class="fa fa-search" />
<InputText v-model="searchQuery" placeholder="Поиск иконки..." class="tw:w-full" />
</IconField>
<div class="tw:grid tw:grid-cols-6 sm:tw:grid-cols-8 md:tw:grid-cols-12 tw:gap-1 tw:max-h-[400px] tw:overflow-y-auto tw:p-1">
<div
v-for="iconName in filteredIcons"
:key="iconName"
class="tw:flex tw:flex-col tw:items-center tw:justify-between tw:p-1 tw:border tw:rounded tw:cursor-pointer hover:tw:bg-blue-50 hover:tw:border-blue-200 tw:transition-colors tw:aspect-square"
:class="{ 'tw:bg-blue-100 tw:border-blue-400': modelValue === iconName }"
@click="selectIcon(iconName)"
>
<div class="tw:flex-1 tw:flex tw:items-center tw:justify-center tw:w-full tw:min-h-0 tw:text-gray-700 tw:[&>svg]:w-15 tw:[&>svg]:h-15" v-html="getIconSvg(iconName)"></div>
<span class="tw:text-[9px] tw:text-gray-500 tw:truncate tw:w-full tw:text-center tw:mt-0.5 tw:flex-shrink-0" :title="iconName">{{ iconName }}</span>
</div>
<div v-if="filteredIcons.length === 0" class="tw:col-span-full tw:text-center tw:text-gray-500 tw:py-8">
Ничего не найдено
</div>
</div>
</div>
</Dialog>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import * as icons from '@formkit/icons';
import {Button, IconField, InputIcon, InputText, Dialog} from 'primevue';
const props = defineProps({
modelValue: {
type: String,
default: null
}
});
const emit = defineEmits(['update:modelValue']);
const visible = ref(false);
const searchQuery = ref('');
// Собираем все иконки из экспорта @formkit/icons
// genesisIcons входит сюда как подмножество, но также там есть и другие наборы (например, feather, fontawesome и т.д. если они были бы установлены,
// но в стандартном пакете @formkit/icons есть только genesis, application, brand, currency, direction, file, input, payment, social, etc.)
// Пройдемся по всему объекту icons и соберем все строки-SVG.
// Но структура экспорта @formkit/icons может быть такой:
// export { genesisIcons } ...
// export { ... }
// Реально пакет содержит много наборов.
// Давайте соберем их все в один плоский список.
const allIconsMap = {};
// Функция для рекурсивного/плоского сбора иконок, если они сгруппированы
Object.entries(icons).forEach(([key, value]) => {
if (typeof value === 'string' && value.startsWith('<svg')) {
// Это прямая иконка (если вдруг)
allIconsMap[key] = value;
} else if (typeof value === 'object' && value !== null) {
// Это группа иконок (например genesisIcons)
Object.entries(value).forEach(([iconName, svgContent]) => {
if (typeof svgContent === 'string' && svgContent.startsWith('<svg')) {
// Если имя уже есть, не перезаписываем или перезаписываем - не важно, главное чтобы был доступ.
// Лучше сохранить оригинальное имя.
allIconsMap[iconName] = svgContent;
}
});
}
});
const allIconNames = Object.keys(allIconsMap).sort();
const filteredIcons = computed(() => {
if (!searchQuery.value) return allIconNames;
const lower = searchQuery.value.toLowerCase();
return allIconNames.filter(name => name.toLowerCase().includes(lower));
});
function getIconSvg(iconName) {
return allIconsMap[iconName];
}
function selectIcon(iconName) {
emit('update:modelValue', iconName);
visible.value = false;
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,235 @@
import { inject, ref } from 'vue';
/**
* Composable для работы с полями формы
*/
export function useFormFields() {
const formFields = inject('formFields');
const selectedFieldId = inject('selectedFieldId');
// Глобальное состояние ошибок полей { [fieldId]: 'Текст ошибки' }
// Используем provide/inject если нужно шарить между компонентами, но пока можно и локально,
// если этот composable используется в provide в родителе.
// В данном случае мы просто добавим ref, но так как composable вызывается в разных местах,
// состояние не будет общим. Нужно вынести состояние выше или использовать provide/inject для ошибок.
// Но для простоты, раз у нас FormBuilder провайдит formFields, добавим и errors туда.
const fieldErrors = inject('fieldErrors', ref({}));
/**
* Выбирает поле по ID
*/
function selectField(fieldId) {
if (selectedFieldId) {
selectedFieldId.value = fieldId;
}
}
/**
* Устанавливает ошибку для поля
*/
function setFieldError(fieldId, error) {
if (!fieldErrors.value) return;
if (error) {
fieldErrors.value[fieldId] = error;
} else {
delete fieldErrors.value[fieldId];
}
}
/**
* Удаляет поле по ID
*/
function removeField(fieldId) {
if (!formFields || !formFields.value) return;
formFields.value = formFields.value.filter(f => f.id !== fieldId);
// Очищаем ошибку при удалении
if (fieldErrors.value[fieldId]) {
delete fieldErrors.value[fieldId];
}
if (selectedFieldId && selectedFieldId.value === fieldId) {
selectedFieldId.value = null;
}
}
/**
* Перемещает поле вверх или вниз (больше не нужно с vuedraggable, но оставим для совместимости/ручного управления)
*/
function moveField(index, direction) {
if (!formFields || !formFields.value) return;
const newFields = [...formFields.value];
if (direction === 'up' && index > 0) {
[newFields[index], newFields[index - 1]] =
[newFields[index - 1], newFields[index]];
formFields.value = newFields;
} else if (direction === 'down' && index < newFields.length - 1) {
[newFields[index], newFields[index + 1]] =
[newFields[index + 1], newFields[index]];
formFields.value = newFields;
}
}
/**
* Генерирует уникальный ID для поля
*/
function generateFieldId() {
let maxId = 0;
if (formFields && formFields.value) {
formFields.value.forEach(field => {
const match = field.id?.match(/field_(\d+)/);
if (match) {
const idNum = parseInt(match[1]);
if (idNum > maxId) maxId = idNum;
}
});
}
return `field_${maxId + 1}_${Date.now()}`;
}
/**
* Проверяет, уникально ли имя поля
*/
function isFieldNameUnique(name, excludeId = null) {
if (!formFields || !formFields.value) return true;
return !formFields.value.some(field =>
field.name === name && field.id !== excludeId
);
}
/**
* Генерирует уникальное имя для поля
*/
function generateUniqueName(baseName) {
let name = baseName;
let counter = 1;
while (!isFieldNameUnique(name)) {
name = `${baseName}_${counter}`;
counter++;
}
return name;
}
/**
* Добавляет новое поле в форму
*/
function addField(fieldConfig, targetIndex = null) {
if (!formFields || !formFields.value) return null;
const id = generateFieldId();
// Генерируем уникальное имя на основе конфига или ID, если имя не задано
let initialName = fieldConfig.name || `field_${id.split('_')[1]}`;
const uniqueName = generateUniqueName(initialName);
const newField = {
id,
...fieldConfig,
name: uniqueName,
};
const newFields = [...formFields.value];
if (targetIndex !== null && targetIndex >= 0) {
newFields.splice(targetIndex + 1, 0, newField);
} else {
newFields.push(newField);
}
formFields.value = newFields;
selectField(newField.id);
return newField;
}
/**
* Обновляет свойства поля
*/
function updateField(fieldId, updates) {
if (!formFields || !formFields.value) return;
const index = formFields.value.findIndex(f => f.id === fieldId);
if (index !== -1) {
const newFields = [...formFields.value];
newFields[index] = { ...newFields[index], ...updates };
formFields.value = newFields;
}
}
/**
* Добавляет опцию к полю
*/
function addFieldOption(fieldId) {
if (!formFields || !formFields.value) return;
const index = formFields.value.findIndex(f => f.id === fieldId);
if (index !== -1) {
const field = formFields.value[index];
const options = field.options ? [...field.options] : [];
options.push({
label: 'Новая опция',
value: `option_${options.length + 1}`,
});
updateField(fieldId, { options });
}
}
/**
* Удаляет опцию у поля
*/
function removeFieldOption(fieldId, optionIndex) {
if (!formFields || !formFields.value) return;
const index = formFields.value.findIndex(f => f.id === fieldId);
if (index !== -1) {
const field = formFields.value[index];
if (!field.options) return;
const options = [...field.options];
options.splice(optionIndex, 1);
updateField(fieldId, { options });
}
}
/**
* Обновляет опцию поля
*/
function updateFieldOption(fieldId, optionIndex, key, value) {
if (!formFields || !formFields.value) return;
const index = formFields.value.findIndex(f => f.id === fieldId);
if (index !== -1) {
const field = formFields.value[index];
if (!field.options) return;
const options = [...field.options];
options[optionIndex] = { ...options[optionIndex], [key]: value };
updateField(fieldId, { options });
}
}
return {
formFields,
selectedFieldId,
fieldErrors, // Экспортируем ошибки
selectField,
removeField,
moveField,
addField,
updateField,
generateFieldId,
isFieldNameUnique,
generateUniqueName,
addFieldOption,
removeFieldOption,
updateFieldOption,
setFieldError, // Экспортируем метод установки ошибки
};
}

View File

@@ -0,0 +1,301 @@
// Доступные типы полей для конструктора
export const AVAILABLE_FIELDS = [
// Поля заказа
{
type: 'firstname_order',
label: 'Имя (Заказ)',
icon: 'fa fa-user',
group: 'order',
defaultConfig: {
$formkit: 'text',
name: 'firstname',
label: 'Имя',
placeholder: 'Например: Иван',
help: 'Введите ваше имя',
validation: 'required|length:0,32',
prefixIcon: "avatarMan",
locked: true,
}
},
{
type: 'lastname_order',
label: 'Фамилия (Заказ)',
icon: 'fa fa-user',
group: 'order',
defaultConfig: {
$formkit: 'text',
name: 'lastname',
label: 'Фамилия',
placeholder: 'Например: Иванов',
help: 'Введите вашу фамилию',
validation: 'required|length:0,32',
prefixIcon: "avatarMan",
locked: true,
}
},
{
type: 'email_order',
label: 'Email (Заказ)',
icon: 'fa fa-envelope',
group: 'order',
defaultConfig: {
$formkit: 'email',
name: 'email',
label: 'E-mail',
placeholder: 'Например: example@mail.com',
help: 'Введите ваш электронный адрес.',
validation: 'required|email|length:0,96',
prefixIcon: "email",
locked: true,
}
},
{
type: 'telephone_order',
label: 'Телефон (Заказ)',
icon: 'fa fa-phone',
group: 'order',
defaultConfig: {
$formkit: 'tel',
name: 'telephone',
label: 'Телефон',
placeholder: 'Например: +7 (999) 000-00-00',
validation: 'required|length:0,32',
help: 'Введите ваш номер телефона.',
prefixIcon: "telephone",
locked: true,
}
},
{
type: 'comment_order',
label: 'Комментарий (Заказ)',
icon: 'fa fa-comment',
group: 'order',
defaultConfig: {
$formkit: 'textarea',
name: 'comment',
label: 'Комментарий к заказу',
placeholder: 'Например: Домофон не работает',
help: 'Дополнительная информация к заказу',
validation: 'length:0,5000',
locked: true,
}
},
{
type: 'shipping_address_1_order',
label: 'Адрес доставки (Заказ)',
icon: 'fa fa-map-marker',
group: 'order',
defaultConfig: {
$formkit: 'textarea',
name: 'shipping_address_1',
label: 'Адрес доставки',
placeholder: 'Например: ул. Ленина, д. 1, кв. 10',
help: 'Укажите улицу, дом и квартиру',
validation: 'required|length:0,128',
locked: true,
}
},
{
type: 'shipping_city_order',
label: 'Город доставки (Заказ)',
icon: 'fa fa-building',
group: 'order',
defaultConfig: {
$formkit: 'text',
name: 'shipping_city',
label: 'Город',
placeholder: 'Например: Москва',
help: 'Город доставки',
validation: 'required|length:0,128',
locked: true,
}
},
{
type: 'shipping_postcode_order',
label: 'Индекс доставки (Заказ)',
icon: 'fa fa-map-pin',
group: 'order',
defaultConfig: {
$formkit: 'text',
name: 'shipping_postcode',
label: 'Почтовый индекс',
placeholder: 'Например: 101000',
help: 'Почтовый индекс',
validation: 'length:0,10',
locked: true,
}
},
{
type: 'shipping_zone_order',
label: 'Регион доставки (Заказ)',
icon: 'fa fa-map',
group: 'order',
defaultConfig: {
$formkit: 'text',
name: 'shipping_zone',
label: 'Регион / Область',
placeholder: 'Например: Московская область',
help: 'Регион или область',
validation: 'length:0,128',
locked: true,
}
},
{
type: 'payment_method_order',
label: 'Способ оплаты (Заказ)',
icon: 'fa fa-money',
group: 'order',
defaultConfig: {
$formkit: 'select',
label: "Способ оплаты заказа",
options: [
{
"label": "Наличными в пункте выдачи",
"value": "Наличными в пункте выдачи"
},
{
"label": "Наличными курьеру",
"value": "Наличными курьеру"
},
{
"label": "Картой курьеру",
"value": "Картой курьеру"
},
{
"label": "В кредит",
"value": "В кредит"
}
],
validation: "required",
name: "payment_method",
prefixIcon: "mastercard",
validationLabel: "Способ оплаты",
help: "Выберите способ оплаты заказа",
locked: true,
}
},
{
type: 'text',
label: 'Текстовое поле',
icon: 'fa fa-font',
defaultConfig: {
$formkit: 'text',
label: 'Текстовое поле',
placeholder: 'Введите текст',
validation: 'required',
}
},
{
type: 'textarea',
label: 'Многострочный текст',
icon: 'fa fa-align-left',
defaultConfig: {
$formkit: 'textarea',
label: 'Многострочный текст',
placeholder: 'Введите текст',
validation: '',
}
},
{
type: 'number',
label: 'Число',
icon: 'fa fa-hashtag',
group: 'general',
defaultConfig: {
$formkit: 'number',
label: 'Число',
placeholder: '0',
validation: '',
}
},
{
type: 'url',
label: 'URL',
icon: 'fa fa-link',
group: 'general',
defaultConfig: {
$formkit: 'url',
label: 'Ссылка',
placeholder: 'https://example.com',
validation: 'url',
}
},
{
type: 'select',
label: 'Выпадающий список',
icon: 'fa fa-list',
group: 'general',
defaultConfig: {
$formkit: 'select',
label: 'Выпадающий список',
options: [
{label: 'Вариант 1', value: 'option1'},
{label: 'Вариант 2', value: 'option2'},
],
validation: 'required',
}
},
{
type: 'checkbox',
label: 'Чекбокс',
icon: 'fa fa-check-square',
group: 'general',
defaultConfig: {
$formkit: 'checkbox',
label: 'Чекбокс',
validation: '',
}
},
{
type: 'radio',
label: 'Радио кнопки',
icon: 'fa fa-dot-circle',
group: 'general',
defaultConfig: {
$formkit: 'radio',
label: 'Радио кнопки',
options: [
{label: 'Вариант 1', value: 'option1'},
{label: 'Вариант 2', value: 'option2'},
],
validation: 'required',
}
},
{
type: 'date',
label: 'Дата',
icon: 'fa fa-calendar',
group: 'general',
defaultConfig: {
$formkit: 'date',
label: 'Дата',
validation: 'required',
}
},
{
type: 'color',
label: 'Цвет',
icon: 'fa fa-palette',
group: 'general',
defaultConfig: {
$formkit: 'color',
label: 'Выберите цвет',
value: '#000000',
validation: '',
}
},
{
type: 'range',
label: 'Диапазон',
icon: 'fa fa-sliders-h',
group: 'general',
defaultConfig: {
$formkit: 'range',
label: 'Диапазон',
min: 0,
max: 100,
step: 1,
validation: '',
}
},
];

View File

@@ -0,0 +1,53 @@
import { PLACEHOLDER_FIELD_TYPES, OPTIONS_FIELD_TYPES } from './fieldTypes.js';
/**
* Получает placeholder для поля (только для поддерживаемых типов)
* @param {Object} field - Объект поля
* @returns {string|undefined} - Placeholder или undefined
*/
export function getPlaceholder(field) {
const type = field.$formkit;
if (!PLACEHOLDER_FIELD_TYPES.includes(type)) {
return undefined;
}
if (field.placeholder && field.placeholder.trim()) {
return field.placeholder.trim();
}
return undefined;
}
/**
* Получает props для поля FormKit для отображения в редакторе
* @param {Object} field - Объект поля
* @returns {Object} - Объект с props для FormKit
*/
export function getFieldProps(field) {
// Создаем копию, исключая служебные поля, которые мы передаем отдельно или не хотим передавать
const { $formkit: _$formkit, id: _id, ...rest } = field;
const props = { ...rest };
// Опции для select и radio
// FormKit принимает массив объектов { label, value }, так что преобразование может не понадобиться
// если формат совпадает. В availableFields мы используем { label, value }.
return props;
}
/**
* Проверяет, поддерживает ли тип поля placeholder
* @param {string} fieldType - Тип поля ($formkit)
* @returns {boolean}
*/
export function supportsPlaceholder(fieldType) {
return PLACEHOLDER_FIELD_TYPES.includes(fieldType);
}
/**
* Проверяет, поддерживает ли тип поля опции
* @param {string} fieldType - Тип поля ($formkit)
* @returns {boolean}
*/
export function supportsOptions(fieldType) {
return OPTIONS_FIELD_TYPES.includes(fieldType);
}

View File

@@ -0,0 +1,30 @@
// Типы полей, которые поддерживают placeholder
export const PLACEHOLDER_FIELD_TYPES = [
'text',
'email',
'textarea',
'number',
'tel',
'url',
'password',
'search'
];
// Типы полей, которые поддерживают опции
export const OPTIONS_FIELD_TYPES = ['select', 'radio'];
// Все поддерживаемые типы полей
export const FIELD_TYPES = {
TEXT: 'text',
EMAIL: 'email',
TEXTAREA: 'textarea',
SELECT: 'select',
CHECKBOX: 'checkbox',
RADIO: 'radio',
DATE: 'date',
NUMBER: 'number',
TEL: 'tel',
URL: 'url',
COLOR: 'color',
RANGE: 'range',
};

View File

@@ -0,0 +1,119 @@
/**
* Утилита для управления ревизиями схемы формы
* Сохраняет предыдущие версии перед деструктивными операциями
*/
const REVISION_STORAGE_KEY = 'formBuilder_revisions';
const MAX_REVISIONS = 10;
/**
* Сохраняет ревизию схемы перед деструктивной операцией
* @param {string} formAlias - Алиас формы (например, 'checkout')
* @param {Array} schema - Схема формы для сохранения
* @param {string} reason - Причина сохранения (например, 'reset_to_visual')
*/
export function saveRevision(formAlias, schema, reason = 'unknown') {
try {
const revisions = getRevisions(formAlias);
const revision = {
id: `rev_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
schema: JSON.parse(JSON.stringify(schema)), // Deep clone
timestamp: new Date().toISOString(),
reason,
};
revisions.unshift(revision);
// Ограничиваем количество ревизий
if (revisions.length > MAX_REVISIONS) {
revisions.splice(MAX_REVISIONS);
}
const storage = getAllRevisions();
storage[formAlias] = revisions;
localStorage.setItem(REVISION_STORAGE_KEY, JSON.stringify(storage));
} catch (error) {
console.error('Ошибка сохранения ревизии:', error);
}
}
/**
* Получает все ревизии для формы
* @param {string} formAlias - Алиас формы
* @returns {Array} Массив ревизий
*/
export function getRevisions(formAlias) {
try {
const storage = getAllRevisions();
return storage[formAlias] || [];
} catch (error) {
console.error('Ошибка получения ревизий:', error);
return [];
}
}
/**
* Получает все ревизии для всех форм
* @returns {Object} Объект с ревизиями по алиасам форм
*/
function getAllRevisions() {
try {
const stored = localStorage.getItem(REVISION_STORAGE_KEY);
return stored ? JSON.parse(stored) : {};
} catch (error) {
console.error('Ошибка чтения ревизий из localStorage:', error);
return {};
}
}
/**
* Восстанавливает схему из ревизии
* @param {string} formAlias - Алиас формы
* @param {string} revisionId - ID ревизии
* @returns {Array|null} Схема формы или null, если ревизия не найдена
*/
export function restoreRevision(formAlias, revisionId) {
try {
const revisions = getRevisions(formAlias);
const revision = revisions.find(r => r.id === revisionId);
if (revision) {
return JSON.parse(JSON.stringify(revision.schema)); // Deep clone
}
return null;
} catch (error) {
console.error('Ошибка восстановления ревизии:', error);
return null;
}
}
/**
* Удаляет ревизию
* @param {string} formAlias - Алиас формы
* @param {string} revisionId - ID ревизии
*/
export function deleteRevision(formAlias, revisionId) {
try {
const revisions = getRevisions(formAlias);
const filtered = revisions.filter(r => r.id !== revisionId);
const storage = getAllRevisions();
storage[formAlias] = filtered;
localStorage.setItem(REVISION_STORAGE_KEY, JSON.stringify(storage));
} catch (error) {
console.error('Ошибка удаления ревизии:', error);
}
}
/**
* Очищает все ревизии для формы
* @param {string} formAlias - Алиас формы
*/
export function clearRevisions(formAlias) {
try {
const storage = getAllRevisions();
delete storage[formAlias];
localStorage.setItem(REVISION_STORAGE_KEY, JSON.stringify(storage));
} catch (error) {
console.error('Ошибка очистки ревизий:', error);
}
}

View File

@@ -0,0 +1,23 @@
/**
* Утилита для работы со схемами форм
* Упрощенная логика: если isCustom=true, схема несовместима с визуальным редактором
*/
/**
* Проверяет совместимость схемы с визуальным редактором
* @param {boolean} isCustom - Флаг кастомной формы
* @returns {boolean} true если схема совместима, false если нет
*/
export function isSchemaCompatible(isCustom) {
// Если форма кастомная, она несовместима с визуальным редактором
return !isCustom;
}
/**
* Создает пустую схему для визуального редактора
* @returns {Array} Пустая схема
*/
export function createEmptySchema() {
return [];
}

View File

@@ -0,0 +1,191 @@
<template>
<div>
<DataTable
:value="logs.logs"
:loading="logs.loading"
paginator
:rows="15"
:rowsPerPageOptions="[15, 50, 100, 200]"
showGridlines
stripedRows
size="small"
sortField="datetime_raw"
:sortOrder="-1"
removableSort
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
:currentPageReportTemplate="`Показано {first} - {last} из {totalRecords} записей`"
>
<template #header>
<div class="tw:flex tw:items-center tw:justify-between tw:gap-2">
<span class="tw:text-sm tw:text-gray-600">Выводятся последние 100 событий</span>
<Button
icon="fa fa-refresh"
@click="logs.fetchLogsFromServer()"
v-tooltip.top="'Обновить журнал'"
size="small"
:loading="logs.loading"
/>
</div>
</template>
<Column header="Действия" :exportable="false" headerStyle="width: 5rem">
<template #body="{ data }">
<Button
icon="fa fa-eye"
severity="secondary"
text
rounded
size="small"
@click="openLogDetails(data)"
v-tooltip.top="'Просмотреть подробности'"
/>
</template>
</Column>
<Column field="datetime" header="Дата и время" sortable sortField="datetime_raw" style="min-width: 180px">
<template #body="{ data }">
<span v-if="data.datetime">{{ data.datetime }}</span>
<span v-else class="tw:text-gray-400"></span>
</template>
</Column>
<Column field="level" header="Уровень" style="min-width: 100px">
<template #body="{ data }">
<Badge
v-if="data.level"
:value="data.level"
:severity="getLevelSeverity(data.level)"
/>
<span v-else class="tw:text-gray-400"></span>
</template>
</Column>
<Column field="channel" header="Канал" style="min-width: 120px">
<template #body="{ data }">
<span v-if="data.channel">{{ data.channel }}</span>
<span v-else class="tw:text-gray-400"></span>
</template>
</Column>
<Column field="message" header="Сообщение" style="min-width: 300px">
<template #body="{ data }">
<div class="tw:break-words">{{ data.message }}</div>
</template>
</Column>
</DataTable>
<Dialog
v-model:visible="showLogDetailsDialog"
modal
header="Подробности лога"
:style="{ width: '800px', maxWidth: '90vw' }"
:closable="true"
:dismissableMask="true"
>
<div v-if="selectedLog" class="tw:space-y-4">
<div>
<label class="tw:block tw:font-semibold tw:mb-1 tw:text-sm">Дата и время:</label>
<div class="tw:text-sm">
<div v-if="selectedLog.datetime">{{ selectedLog.datetime }}</div>
<div v-if="selectedLog.datetime_raw && selectedLog.datetime_raw !== selectedLog.datetime" class="tw:text-gray-500 tw:text-xs tw:mt-1">
({{ selectedLog.datetime_raw }})
</div>
<span v-if="!selectedLog.datetime" class="tw:text-gray-400"></span>
</div>
</div>
<div>
<label class="tw:block tw:font-semibold tw:mb-1 tw:text-sm">Уровень:</label>
<span
v-if="selectedLog.level"
:class="{
'tw:text-red-600 tw:font-bold': selectedLog.level === 'ERROR' || selectedLog.level === 'CRITICAL',
'tw:text-orange-600': selectedLog.level === 'WARNING',
'tw:text-blue-600': selectedLog.level === 'INFO',
'tw:text-gray-600': selectedLog.level === 'DEBUG',
}"
class="tw:text-sm"
>
{{ selectedLog.level }}
</span>
<span v-else class="tw:text-gray-400 tw:text-sm"></span>
</div>
<div>
<label class="tw:block tw:font-semibold tw:mb-1 tw:text-sm">Канал:</label>
<span v-if="selectedLog.channel" class="tw:text-sm">{{ selectedLog.channel }}</span>
<span v-else class="tw:text-gray-400 tw:text-sm"></span>
</div>
<div>
<label class="tw:block tw:font-semibold tw:mb-1 tw:text-sm">Сообщение:</label>
<div class="tw:text-sm tw:bg-gray-50 tw:p-3 tw:rounded tw:break-words tw:whitespace-pre-wrap">{{ selectedLog.message || '—' }}</div>
</div>
<div v-if="selectedLog.context">
<label class="tw:block tw:font-semibold tw:mb-1 tw:text-sm">Контекст:</label>
<pre class="tw:text-xs tw:bg-gray-100 tw:p-3 tw:rounded tw:overflow-auto tw:max-h-96 tw:border tw:border-gray-200 tw:whitespace-pre-wrap tw:break-words">{{ JSON.stringify(selectedLog.context, null, 2) }}</pre>
</div>
<div>
<label class="tw:block tw:font-semibold tw:mb-1 tw:text-sm">Исходная строка:</label>
<pre class="tw:text-xs tw:bg-gray-100 tw:p-3 tw:rounded tw:overflow-auto tw:max-h-48 tw:border tw:border-gray-200 tw:whitespace-pre-wrap tw:break-words">{{ selectedLog.raw }}</pre>
</div>
</div>
<template #footer>
<Button
label="Закрыть"
icon="fa fa-times"
severity="secondary"
@click="closeLogDetailsDialog"
/>
</template>
</Dialog>
</div>
</template>
<script setup>
import { onMounted, ref } from "vue";
import { useLogsStore } from "@/stores/logs.js";
import DataTable from "primevue/datatable";
import Column from "primevue/column";
import Button from "primevue/button";
import Dialog from "primevue/dialog";
import Badge from "primevue/badge";
const logs = useLogsStore();
const showLogDetailsDialog = ref(false);
const selectedLog = ref(null);
function openLogDetails(log) {
selectedLog.value = log;
showLogDetailsDialog.value = true;
}
function closeLogDetailsDialog() {
showLogDetailsDialog.value = false;
selectedLog.value = null;
}
function getLevelSeverity(level) {
switch (level) {
case 'ERROR':
case 'CRITICAL':
return 'danger';
case 'WARNING':
return 'warn';
case 'INFO':
return 'info';
case 'DEBUG':
return 'secondary';
default:
return null;
}
}
onMounted(async () => logs.fetchLogsFromServer());
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,63 @@
<template>
<div>
<div class="tw:flex tw:justify-between tw:items-start">
<div>
<h3 class="p-card-title">
{{ title }}
</h3>
<slot/>
</div>
<div class="tw:flex tw:items-center tw:gap-2">
<Button
icon="fa fa-cog"
severity="contrast"
rounded
text
@click="$emit('onShowSettings')"
/>
<Button
icon="fa fa-trash"
severity="danger"
rounded
text
@click="confirmedRemove($event)"
/>
</div>
</div>
</div>
</template>
<script setup>
import {Button, useConfirm} from "primevue";
const props = defineProps({
title: {
type: String,
default: null,
},
});
const confirm = useConfirm();
const emit = defineEmits(['onRemove', 'onShowSettings']);
function confirmedRemove(event) {
confirm.require({
group: 'popup',
target: event.currentTarget,
message: 'Удалить блок?',
icon: 'pi pi-exclamation-triangle',
rejectProps: {
label: 'Отмена',
severity: 'secondary',
outlined: true
},
acceptProps: {
label: 'Удалить',
severity: 'danger'
},
accept: () => emit('onRemove'),
});
}
</script>

View File

@@ -0,0 +1,29 @@
<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">Кол-во категорий:</span>
{{ value.data.count }}
</div>
</div>
</BaseBlock>
</template>
<script setup>
import BaseBlock from "@/components/MainPageConfigurator/Blocks/BaseBlock.vue";
const emit = defineEmits(['onRemove', 'onShowSettings']);
const props = defineProps({
value: {
type: Object,
required: true,
}
});
</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

@@ -0,0 +1,30 @@
<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">Максимальное кол-во страниц:</span>
{{ value.data.max_page_count }}
</div>
</div>
</BaseBlock>
</template>
<script setup>
import BaseBlock from "@/components/MainPageConfigurator/Blocks/BaseBlock.vue";
const emit = defineEmits(['onRemove', 'onShowSettings']);
const props = defineProps({
value: {
type: Object,
required: true,
}
});
</script>

View File

@@ -0,0 +1,46 @@
<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.is_enabled ? 'Включен' : 'Выключен' }}
</div>
<div><span class="tw:font-bold tw:dark:text-slate-200">Эффект:</span>
{{ sliderEffectOptions[value.data.effect] || value.data.effect }}
</div>
<div><span class="tw:font-bold tw:dark:text-slate-200">Авто:</span>
{{ value.data.autoplay ? 'Включен' : 'Выключен' }}
</div>
<div><span class="tw:font-bold tw:dark:text-slate-200">Цель Яндекс.Метрики:</span>
{{ value.goal_name || 'Не задана' }}
</div>
</div>
<div class="tw:mt-6 tw:flex tw:flex-wrap tw:gap-4">
<img
v-if="value.data.slides && value.data.slides.length > 0"
v-for="slide in value.data.slides"
:alt="slide.title"
class="tw:w-24 tw:h-24 tw:object-cover tw:rounded-md tw:border-2 tw:border-slate-200 dark:tw:border-slate-600"
:src="getThumb(slide.image)"
/>
</div>
</BaseBlock>
</template>
<script setup>
import {getThumb} from "@/utils/helpers.js";
import {sliderEffectOptions} from "@/utils/constants..js";
import BaseBlock from "@/components/MainPageConfigurator/Blocks/BaseBlock.vue";
const emit = defineEmits(['onRemove', 'onShowSettings']);
const props = defineProps({
value: {
type: Object,
required: true,
}
});
</script>

View File

@@ -0,0 +1,31 @@
<template>
<Dropdown
v-model="model"
:options="aspectRatioOptions"
optionLabel="label"
optionValue="value"
placeholder="Выберите соотношение"
class="tw:w-full md:tw:w-96"
>
<template #option="slotProps">
<div class="tw:flex tw:flex-col">
<span class="tw:font-medium">{{ slotProps.option.label }}</span>
<span class="tw:text-xs tw:text-gray-500 tw:whitespace-normal">{{ slotProps.option.description }}</span>
</div>
</template>
</Dropdown>
</template>
<script setup>
import { Dropdown } from "primevue";
const model = defineModel();
const aspectRatioOptions = [
{ label: '1:1', value: '1:1', description: 'Универсально, аксессуары, мелкие товары, удобно для всех товаров — идеально для сетки.' },
{ label: '4:5', value: '4:5', description: 'Одежда, обувь, вертикальные товары, где нужно показать высоту (футболки, платья).' },
{ label: '3:4', value: '3:4', description: 'Одежда, обувь, вертикальные товары, где нужно показать высоту (футболки, платья).' },
{ label: '2:3', value: '2:3', description: 'Цветы, высокие предметы (бутылки, букеты, декоративные элементы).' },
];
</script>

View File

@@ -0,0 +1,130 @@
<template>
<Tabs value="0">
<TabList>
<Tab value="0">Настройки блока</Tab>
<Tab value="1">Основные настройки</Tab>
<slot name="tabs"></slot>
</TabList>
<TabPanels>
<TabPanel value="0">
<div class="tw:space-y-6">
<!-- Статус -->
<div>
<div class="tw:flex tw:items-center tw:justify-between tw:mb-2">
<label class="tw:font-medium tw:text-gray-700">
Статус
</label>
<ToggleSwitch v-model="model.is_enabled"/>
</div>
<small class="tw:block tw:text-sm tw:text-gray-500">
Показывать этот блок
</small>
</div>
<!-- Заголовок блока -->
<div>
<div class="tw:mb-2">
<label class="tw:font-medium tw:text-gray-700">
Заголовок блока
</label>
<InputText
v-model="model.title"
placeholder="заголовок блока"
class="tw:w-full"
/>
</div>
<small class="tw:block tw:text-sm tw:text-gray-500">
Текст, который будет выводиться в качестве заголовка блока на главной странице. Оставьте
пустым, если заголовок не требуется.
</small>
</div>
<!-- Описание блока -->
<div>
<div class="tw:mb-2">
<label class="tw:font-medium tw:text-gray-700">
Описание блока
</label>
<InputText
v-model="model.description"
placeholder="Описание блока"
class="tw:w-full"
/>
</div>
<small class="tw:block tw:text-sm tw:text-gray-500">
Описание выводится под заголовком блока уменьшенным шрифтом. Оставьте пустым, если
описание не требуется.
</small>
</div>
<!-- Цель Яндекс.Метрики -->
<div>
<div class="tw:mb-2">
<label class="tw:font-medium tw:text-gray-700">
Цель Яндекс.Метрики
</label>
<InputText
v-model="model.goal_name"
placeholder="Название цели для Яндекс.Метрики"
class="tw:w-full"
/>
</div>
<small class="tw:block tw:text-sm tw:text-gray-500">
Цель в Яндекс.Метрике для отслеживания кликов по блоку.
Оставьте пустым, если не нужно отслеживать клики по этому блоку.
</small>
</div>
</div>
</TabPanel>
<TabPanel value="1">
<slot></slot>
</TabPanel>
<slot name="panels"></slot>
</TabPanels>
</Tabs>
<Divider/>
<div class="tw:flex tw:items-center tw:justify-between tw:gap-4">
<div class="tw:flex tw:gap-2">
<Button
label="Применить"
icon="fa fa-check"
v-tooltip.top="isChanged ? 'Применить изменения' : 'Нет изменений для сохранения'"
:disabled="isChanged === false"
@click="onApply"
/>
<Button label="Отмена" severity="secondary" @click="$emit('cancel')"/>
</div>
<div v-if="isChanged" class="tw:flex tw:items-center tw:gap-2 tw:text-amber-600">
<i class="fa fa-exclamation-triangle"></i>
<span class="tw:text-sm">Есть несохранённые изменения</span>
</div>
</div>
</template>
<script setup>
import {Button, Divider, InputText, Panel, ToggleSwitch} from 'primevue';
import Tabs from 'primevue/tabs';
import TabList from 'primevue/tablist';
import Tab from 'primevue/tab';
import TabPanels from 'primevue/tabpanels';
import TabPanel from 'primevue/tabpanel';
const model = defineModel();
const emit = defineEmits(['onApply', 'cancel']);
const props = defineProps({
isChanged: {
type: Boolean,
default: false,
}
});
function onApply() {
emit('onApply');
}
</script>

View File

@@ -0,0 +1,55 @@
<template>
<div v-if="draft">
<BaseForm
v-model="draft"
:isChanged="isChanged"
@onApply="onApply"
@cancel="$emit('cancel')"
>
<div class="tw:space-y-6">
<!-- Количество категорий -->
<FormItem label="Количество категорий">
<template #default>
<InputNumber
v-model="draft.data.count"
:min="0"
:max="100"
placeholder="10"
:showButtons="true"
/>
<span class="tw:text-gray-600 tw:whitespace-nowrap">шт.</span>
</template>
<template #help>
Количество категорий, которое нужно выводить в блоке. Если поставить 0, то будет
выводиться только кнопка "Каталог".
</template>
</FormItem>
</div>
</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 {InputNumber, Panel} from "primevue";
import FormItem from "@/components/MainPageConfigurator/Forms/FormItem.vue";
const draft = ref(null);
const model = defineModel();
const emit = defineEmits(['cancel']);
const isChanged = computed(() => md5(JSON.stringify(model.value)) !== md5(JSON.stringify(draft.value)));
function onApply() {
model.value = JSON.parse(JSON.stringify(draft.value));
}
onMounted(() => {
draft.value = JSON.parse(JSON.stringify(model.value));
});
defineExpose({isChanged});
</script>

View File

@@ -0,0 +1,25 @@
<template>
<!-- Расстояние между слайдами -->
<div>
<div class="tw:flex tw:items-center tw:justify-between tw:mb-2 tw:gap-4">
<label v-if="label" class="tw:font-medium tw:text-gray-700 tw:flex-shrink-0">
{{ label }}
</label>
<div class="tw:flex tw:items-center tw:gap-2 tw:flex-shrink-0">
<slot/>
</div>
</div>
<small class="tw:block tw:text-sm tw:text-gray-500">
<slot name="help"/>
</small>
</div>
</template>
<script setup>
const props = defineProps({
label: {
type: String,
default: null,
},
});
</script>

View File

@@ -0,0 +1,254 @@
<template>
<div v-if="draft">
<BaseForm
v-model="draft"
:isChanged="isChanged"
@onApply="onApply"
@cancel="$emit('cancel')"
>
<template #default>
<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>
<!-- Свободный режим -->
<FormItem label="Свободный режим">
<template #default>
<ToggleSwitch v-model="freeMode"/>
</template>
<template #help>
Включает «свободный режим» прокрутки слайдов без привязки к конкретным индексам.
Слайды прокручиваются плавно, скорость зависит от инерции свайпа.
</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(() => {
return 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;
}
}
});
const freeMode = computed({
get() {
const freemode = draft.value.data.carousel?.freemode;
if (freemode && typeof freemode === 'object' && freemode.enabled) {
return freemode.enabled;
}
return false;
},
set(value) {
ensureCarousel();
// Убеждаемся, что autoplay - это объект
if (!draft.value.data.carousel.freemode) {
draft.value.data.carousel.freemode = {};
draft.value.data.carousel.freemode.enabled = value;
} else {
draft.value.data.carousel.freemode.enabled = 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

@@ -0,0 +1,69 @@
<template>
<div v-if="draft">
<BaseForm
v-model="draft"
:isChanged="isChanged"
@onApply="onApply"
@cancel="$emit('cancel')"
>
<div class="tw:space-y-6">
<!-- Максимальное количество страниц -->
<FormItem label="Максимальное количество страниц">
<template #default>
<InputNumber
v-model="draft.data.max_page_count"
:min="1"
:max="100"
placeholder="10"
:showButtons="true"
/>
<span class="tw:text-gray-600 tw:whitespace-nowrap">страниц</span>
</template>
<template #help>
Укажите, сколько страниц товаров можно подгружать при бесконечной прокрутки.
После достижения этого лимита подгрузка остановится.
Ограничение страниц снижает нагрузку на сервер.
</template>
</FormItem>
</div>
</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 {InputNumber} from "primevue";
import FormItem from "@/components/MainPageConfigurator/Forms/FormItem.vue";
const draft = ref(null);
const model = defineModel();
const emit = defineEmits(['cancel']);
const isChanged = computed(() => {
const normalize = (obj) => {
return JSON.stringify(obj, (key, value) => {
if (['max_page_count'].includes(key)) {
return value !== null && value !== undefined && value !== '' ? parseInt(value) : value;
}
return value;
});
};
return md5(normalize(model.value)) !== md5(normalize(draft.value));
});
function onApply() {
model.value = JSON.parse(JSON.stringify(draft.value));
}
onMounted(() => {
draft.value = JSON.parse(JSON.stringify(model.value));
if (draft.value.data) {
if (draft.value.data.max_page_count) draft.value.data.max_page_count = parseInt(draft.value.data.max_page_count);
}
});
defineExpose({isChanged});
</script>

View File

@@ -0,0 +1,271 @@
<template>
<div v-if="draft">
<BaseForm
v-model="draft"
:isChanged="isChanged"
@onApply="onApply"
@cancel="$emit('cancel')"
>
<!-- Основные настройки -->
<Panel header="Основные настройки" class="tw:mb-4">
<div class="tw:space-y-6">
<!-- Эффект смены слайдов -->
<div>
<div class="tw:flex tw:items-center tw:justify-between tw:mb-2">
<label class="tw:font-medium tw:text-gray-700">
Эффект смены слайдов
</label>
<Dropdown
v-model="draft.data.effect"
:options="effectOptionsList"
optionLabel="label"
optionValue="value"
placeholder="Выберите эффект"
class="tw:w-64"
/>
</div>
</div>
<!-- Пагинация -->
<div>
<div class="tw:flex tw:items-center tw:justify-between tw:mb-2">
<label class="tw:font-medium tw:text-gray-700">
Пагинация
</label>
<ToggleSwitch v-model="draft.data.pagination"/>
</div>
<small class="tw:block tw:text-sm tw:text-gray-500">
Показывать точки под слайдером для индикации текущего слайда.
</small>
</div>
<!-- Полоса прокрутки -->
<div>
<div class="tw:flex tw:items-center tw:justify-between tw:mb-2">
<label class="tw:font-medium tw:text-gray-700">
Полоса прокрутки
</label>
<ToggleSwitch v-model="draft.data.scrollbar"/>
</div>
<small class="tw:block tw:text-sm tw:text-gray-500">
Показывать полосу прокрутки под слайдером для навигации между слайдами.
</small>
</div>
<!-- Расстояние между слайдами -->
<div>
<div class="tw:flex tw:items-center tw:justify-between tw:mb-2 tw:gap-4">
<label class="tw:font-medium tw:text-gray-700 tw:flex-shrink-0">
Расстояние между слайдами
</label>
<div class="tw:flex tw:items-center tw:gap-2 tw:flex-shrink-0">
<InputNumber
v-model="draft.data.space_between"
:min="0"
:max="100"
placeholder="30"
:showButtons="true"
/>
<span class="tw:text-gray-600 tw:whitespace-nowrap">px</span>
</div>
</div>
<small class="tw:block tw:text-sm tw:text-gray-500">
Расстояние между слайдами в пикселях. По умолчанию - 30.
</small>
</div>
<!-- Свободный режим -->
<div>
<div class="tw:flex tw:items-center tw:justify-between tw:mb-2">
<label class="tw:font-medium tw:text-gray-700">
Свободный режим
</label>
<ToggleSwitch v-model="draft.data.free_mode"/>
</div>
<small class="tw:block tw:text-sm tw:text-gray-500">
Позволяет свободно прокручивать слайды без привязки к конкретным позициям.
</small>
</div>
<!-- Бесконечная прокрутка -->
<div>
<div class="tw:flex tw:items-center tw:justify-between tw:mb-2">
<label class="tw:font-medium tw:text-gray-700">
Бесконечная прокрутка
</label>
<ToggleSwitch v-model="draft.data.loop"/>
</div>
<small class="tw:block tw:text-sm tw:text-gray-500">
Включите этот режим, чтобы после последнего слайда слайдер продолжал прокрутку с
первого, создавая бесконечный цикл.
</small>
</div>
<!-- Автоматическая прокрутка -->
<div>
<div class="tw:flex tw:items-center tw:justify-between tw:mb-2">
<label class="tw:font-medium tw:text-gray-700">
Автоматическая прокрутка
</label>
<ToggleSwitch v-model="draft.data.autoplay"/>
</div>
<small class="tw:block tw:text-sm tw:text-gray-500">
Слайдер будет автоматически листать изображения каждые 3 секунды.
</small>
</div>
</div>
</Panel>
<!-- Слайды -->
<Panel header="Слайды">
<template #icons>
<Button
severity="success"
text
rounded
aria-label="Добавить слайд"
@click="addSlide"
>
<i class="fa fa-plus"></i> Добавить новый слайд
</Button>
</template>
<div v-if="draft.data.slides.length === 0" class="tw:text-center tw:py-8 tw:text-gray-500">
<i class="fa fa-image fa-3x tw:mb-4"></i>
<p class="tw:font-bold">Слайды не добавлены</p>
<Button
label="Добавить первый слайд"
severity="success"
outlined
class="tw:mt-4"
@click="addSlide"
>
<i class="fa fa-plus"></i>
</Button>
</div>
<div v-else class="tw:space-y-4">
<div
v-for="(slide, index) in draft.data.slides"
:key="index"
class="tw:bg-white tw:rounded-lg tw:border tw:border-gray-200 tw:p-4 tw:shadow-sm tw:relative"
>
<div class="tw:absolute tw:top-2 tw:right-2">
<Button
severity="danger"
text
rounded
aria-label="Удалить слайд"
@click="removeSlide($event, index)"
>
<i class="fa fa-trash tw:text-lg"></i>
</Button>
</div>
<div class="tw:flex">
<!-- Изображение -->
<div class="tw:mr-5">
<label class="tw:block tw:mb-2 tw:font-medium tw:text-gray-700">
Изображение
</label>
<OcImagePicker v-model="slide.image"/>
</div>
<!-- Поля -->
<div class="tw:space-y-4">
<div>
<label class="tw:block tw:mb-2 tw:font-medium tw:text-gray-700">
Заголовок слайда
</label>
<InputText
v-model="slide.title"
placeholder="Введите заголовок слайда"
class="tw:w-full"
/>
<small class="tw:block tw:text-sm tw:text-gray-500">
Заголовок слайда будет отправляться в цели Яндекс.Метрики
</small>
</div>
<div>
<label class="tw:block tw:mb-2 tw:font-medium tw:text-gray-700">
Ссылка
</label>
<LinkSelector v-model="slide.link"/>
</div>
</div>
</div>
</div>
</div>
</Panel>
</BaseForm>
</div>
</template>
<script setup>
import {computed, defineExpose, onMounted, ref} from "vue";
import OcImagePicker from "@/components/OcImagePicker.vue";
import LinkSelector from "@/components/Slider/LinkSelector.vue";
import {Button, Dropdown, InputNumber, InputText, Panel, ToggleSwitch, useConfirm} from 'primevue';
import {sliderEffectOptions} from "@/utils/constants..js";
import {md5} from "js-md5";
import BaseForm from "@/components/MainPageConfigurator/Forms/BaseForm.vue";
const confirm = useConfirm();
const draft = ref(null);
const slider = defineModel();
const isChanged = computed(() => md5(JSON.stringify(slider.value)) !== md5(JSON.stringify(draft.value)));
const effectOptionsList = computed(() => {
return Object.entries(sliderEffectOptions).map(([value, label]) => ({
value,
label,
}));
});
function removeSlide(event, index) {
confirm.require({
group: 'popup',
target: event.currentTarget,
message: 'Удалить слайд?',
icon: 'pi pi-exclamation-triangle',
rejectProps: {
label: 'Отмена',
severity: 'secondary',
outlined: true
},
acceptProps: {
label: 'Удалить',
severity: 'danger'
},
accept: () => draft.value.data.slides.splice(index, 1),
});
}
function addSlide() {
draft.value.data.slides.push({
title: '',
link: {
type: 'none',
value: null,
},
image: '',
});
}
function onApply() {
slider.value = JSON.parse(JSON.stringify(draft.value));
}
onMounted(() => {
draft.value = JSON.parse(JSON.stringify(slider.value));
});
defineExpose({isChanged});
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,197 @@
<template>
<div class="tw:flex tw:gap-4">
<section class="tw:w-1/3 tw:p-4 tw:bg-slate-100 tw:rounded-lg">
<header class="tw:font-semibold tw:text-lg tw:uppercase">Доступные блоки</header>
<div class="tw:mb-6">Перетяните блок, чтобы добавить на главную страницу</div>
<draggable
v-model="availableBlocks"
:group="{ name: 'blocks', pull: 'clone', put: false }"
:clone="cloneBlock"
item-key="type"
class="tw:space-y-2"
chosenClass="tw:scale-98"
>
<template #item="{ element, index }">
<Card class="tw:cursor-move">
<template #title>
<i class="fa fa-arrows"></i>
{{ element.title }}
</template>
<template #content>
<p class="m-0">
{{ element.description }}
</p>
</template>
</Card>
</template>
</draggable>
</section>
<section class="tw:w-full tw:rounded-xl tw:p-4 tw:bg-slate-100 tw:min-h-[400px] tw:relative">
<header class="tw:font-semibold tw:text-lg tw:uppercase">Блоки на главной странице</header>
<div class="tw:mb-6">Эти блоки будут отображены на главной странице в том же порядке. Перетяните блок, если хотите изменить порядок.</div>
<draggable
v-model="settings.items.mainpage_blocks"
:group="{ name: 'blocks', put: true }"
item-key="type"
class="tw:w-full tw:h-full tw:min-h-[400px] tw:space-y-2"
@change="onChange"
>
<template #item="{ element, index }">
<template v-if="blockToComponentMap[element.type]">
<div class="tw:bg-white tw:rounded-lg tw:p-6 tw:border tw:border-slate-200">
<component
:is="blockToComponentMap[element.type]"
:value="element"
@onRemove="removeBlock(index)"
@onShowSettings="showDrawer(index)"
/>
</div>
</template>
<div v-else>неподдерживаемый блок</div>
</template>
</draggable>
<div
v-if="!hasBlocks"
class="tw:absolute tw:inset-0 tw:flex tw:flex-col tw:items-center tw:justify-center tw:text-center tw:py-12 tw:px-4 tw:pointer-events-none"
>
<div class="tw:mb-6 tw:text-6xl tw:text-gray-400">
<i class="fa fa-inbox"></i>
</div>
<h3 class="tw:text-xl tw:font-semibold tw:text-gray-700 tw:mb-2">
Нет блоков на главной странице
</h3>
<p class="tw:text-gray-500 tw:max-w-md tw:mb-4">
Перетащите блок из левой панели, чтобы добавить его на главную страницу
</p>
</div>
</section>
<Drawer
:visible="isDrawerSettingsVisible"
@update:visible="closeDrawer"
:header="drawerTitle"
position="right"
:baseZIndex="1000"
class="tw:!w-full tw:md:!w-80 tw:lg:!w-[50rem]"
>
<template v-if="currentBlock && blockToFormMap[currentBlock.type]">
<component
:is="blockToFormMap[currentBlock.type]"
ref="currentBlockForm"
@cancel="closeDrawer"
:modelValue="settings.items.mainpage_blocks[drawerBlockIndex]"
@update:modelValue="updateBlockData"
/>
</template>
<div v-else>Unsupported block type</div>
</Drawer>
</div>
</template>
<script setup>
import draggable from 'vuedraggable';
import {Card, Drawer, useConfirm} from 'primevue';
import {computed, nextTick, ref} from "vue";
import {useSettingsStore} from "@/stores/settings.js";
import {
blocks,
blockToComponentMap,
blockToFormMap
} from "@/components/MainPageConfigurator/availableBlocks.js";
const settings = useSettingsStore();
const confirm = useConfirm();
const availableBlocks = ref(blocks);
const isDrawerSettingsVisible = ref(null);
const drawerBlockIndex = ref(null);
const currentBlockForm = ref(null);
const currentBlock = computed(() => {
if (drawerBlockIndex.value >= 0) {
return settings.items.mainpage_blocks[drawerBlockIndex.value];
}
return null;
});
const drawerTitle = computed(() => {
if (currentBlock.value) {
return `Редактирование ${currentBlock?.value?.type} - ${currentBlock?.value?.title || 'Без заголовка'}`;
}
return '';
});
const hasBlocks = computed(() => {
return settings.items.mainpage_blocks && settings.items.mainpage_blocks.length > 0;
});
function removeBlock(index) {
settings.items.mainpage_blocks.splice(index, 1);
}
function cloneBlock(block) {
const newBlock = JSON.parse(JSON.stringify(block));
newBlock.title = '';
newBlock.description = '';
return newBlock;
}
function showDrawer(blockIndex) {
if (currentBlock.value !== null) {
drawerBlockIndex.value = blockIndex;
isDrawerSettingsVisible.value = true;
}
}
function closeDrawer() {
// Проверяем, есть ли несохраненные изменения
if (currentBlockForm.value?.isChanged === true) {
confirm.require({
message: 'У вас есть несохраненные изменения. Вы уверены, что хотите закрыть форму?',
header: 'Подтверждение закрытия',
icon: 'pi pi-exclamation-triangle',
rejectProps: {
label: 'Отмена',
severity: 'secondary',
outlined: true
},
acceptProps: {
label: 'Закрыть',
severity: 'danger'
},
accept: () => {
drawerBlockIndex.value = null;
isDrawerSettingsVisible.value = false;
}
});
} else {
drawerBlockIndex.value = null;
isDrawerSettingsVisible.value = false;
}
}
function onChange(update) {
if (update.added && update.added?.newIndex >= 0) {
showDrawer(update.added.newIndex);
}
}
function updateBlockData(newBlockData) {
if (drawerBlockIndex.value !== null && drawerBlockIndex.value >= 0) {
settings.items.mainpage_blocks.splice(drawerBlockIndex.value, 1, newBlockData);
nextTick(() => closeDrawer());
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,82 @@
import SliderBlock from "@/components/MainPageConfigurator/Blocks/SliderBlock.vue";
import CategoriesTopBlock from "@/components/MainPageConfigurator/Blocks/CategoriesTopBlock.vue";
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 = [
{
type: 'slider',
title: 'Слайдер',
description: 'Изображения объединённые в слайдер.',
is_enabled: true,
goal_name: '',
data: {
effect: "slide",
pagination: true,
scrollbar: false,
free_mode: false,
space_between: 5,
autoplay: false,
loop: false,
slides: [],
},
},
{
type: 'categories_top',
title: 'Топ категорий',
description: 'Виджет с кнопками популярных категорий и кнопкой «Каталог» для всех категорий.',
is_enabled: true,
goal_name: '',
data: {
count: 10,
},
},
{
type: 'products_feed',
title: 'Лента товаров',
description: 'Отображает товары в виде прокручиваемой ленты с возможностью подгрузки новых элементов по мере скролла.',
is_enabled: true,
goal_name: '',
data: {
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: 2.5,
space_between: 10,
autoplay: false,
freemode: {
enabled: false,
},
},
},
},
];

View File

@@ -0,0 +1,54 @@
<template>
<div class="oc-image">
<div v-if="isLoaded === false" class="loader">
<i class="fa fa-spinner fa-spin"></i>
</div>
<a v-show="isLoaded" href="#" data-toggle="image" class="img-thumbnail" :id="`thumb-image-${id}`">
<img
:src="thumb"
data-placeholder="/image/cache/no_image-100x100.png"
alt="Image"
@load="isLoaded = true"
style="width: 100%; height: 100%;"
>
</a>
<input ref="inputRef" type="hidden" value="" :id="`input-image-${id}`">
</div>
</template>
<script setup>
import {computed, onMounted, ref, useId} from "vue";
import {getThumb} from "@/utils/helpers.js";
const id = useId();
const model = defineModel();
const emit = defineEmits(['update:modelValue']);
const inputRef = ref(null);
const isLoaded = ref(false);
const thumb = computed(() => getThumb(model.value));
onMounted(() => {
const input = inputRef.value;
const observer = new MutationObserver(() => {
const val = input.value;
console.log("Updated value: ", val);
if (val !== model.value) {
emit('update:modelValue', val);
}
});
observer.observe(input, {attributes: true, attributeFilter: ['value']});
});
</script>
<style scoped>
.loader {
width: 100px;
height: 100px;
display: flex;
justify-content: center;
align-items: center;
}
</style>

View File

@@ -0,0 +1,133 @@
<template>
<div class="tw:space-y-2">
<textarea
ref="textareaRef"
class="form-control"
:placeholder="placeholder"
></textarea>
</div>
</template>
<script setup>
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
const props = defineProps({
placeholder: {
type: String,
default: "",
},
height: {
type: Number,
default: 240,
},
});
const model = defineModel({
type: String,
default: "",
});
const textareaRef = ref(null);
const summernoteInstance = ref(null);
const getJQuery = () => window.$ || window.jQuery;
const normalizeTelegramHtml = (html = "") => {
const withoutEmptyParagraphs = html.replace(/<p><br><\/p>/gi, "<br>");
const withoutParagraphs = withoutEmptyParagraphs
.replace(/<p>/gi, "")
.replace(/<\/p>/gi, "<br>");
return withoutParagraphs.replace(/(?:<br>\s*)+$/i, "").trim();
};
const makeSpoilerButton = ($) => (context) => {
const ui = $.summernote.ui;
return ui
.button({
contents: '<i class="fa fa-eye-slash"></i>',
tooltip: "Спойлер (Telegram)",
click() {
const selectedText = context.invoke("editor.getSelectedText") || "";
const content = selectedText || "spoiler";
context.invoke(
"editor.pasteHTML",
`<span class="tg-spoiler">${content}</span>`
);
},
})
.render();
};
onMounted(() => {
const $ = getJQuery();
if (!$ || !textareaRef.value) {
console.warn("[RichTextEditor] jQuery или textarea недоступны");
return;
}
const $el = $(textareaRef.value);
$el.summernote({
height: props.height,
placeholder: props.placeholder,
shortcuts: false,
dialogsInBody: true,
disableResizeEditor: true,
buttons: {
spoiler: makeSpoilerButton($),
},
toolbar: [
["font", ["bold", "underline", "italic", "strikethrough", "clear"]],
["para", ["ul", "ol", "paragraph"]],
["insert", ["link", "spoiler"]],
["view", ["fullscreen", "codeview", "help"]],
],
callbacks: {
onChange(contents) {
const normalized = normalizeTelegramHtml(contents ?? "");
if (normalized !== contents) {
$el.summernote("code", normalized);
return;
}
model.value = normalized;
},
onKeydown(e) {
if (e.keyCode === 13) {
e.preventDefault();
$el.summernote("pasteHTML", "<br>");
}
},
},
});
if (model.value) {
$el.summernote("code", normalizeTelegramHtml(model.value));
}
summernoteInstance.value = $el;
});
watch(
model,
(value) => {
const instance = summernoteInstance.value;
if (!instance) {
return;
}
const normalized = normalizeTelegramHtml(value || "");
const current = normalizeTelegramHtml(instance.summernote("code"));
if (current !== normalized) {
instance.summernote("code", normalized);
}
}
);
onBeforeUnmount(() => {
if (summernoteInstance.value) {
summernoteInstance.value.summernote("destroy");
summernoteInstance.value = null;
}
});
</script>

View File

@@ -0,0 +1,108 @@
<template>
<div class="tw:border tw:border-gray-300 tw:rounded-md tw:bg-white tw:overflow-hidden">
<div class="tw:flex tw:flex-col">
<div
v-for="job in scheduledJobs"
:key="job.id"
class="tw:flex tw:items-center tw:gap-4 tw:py-3 tw:px-3 tw:border-b tw:border-gray-200 tw:last:border-b-0"
>
<ToggleSwitch
:model-value="Boolean(job.is_enabled)"
@update:model-value="onJobToggle(job, $event)"
/>
<div class="tw:min-w-0 tw:flex-1 tw:flex tw:flex-col tw:gap-0.5">
<span class="tw:text-[inherit] tw:font-bold">{{ hasJobMeta(job.name) ? jobMeta(job.name).friendlyName : job.name }}</span>
<span v-if="hasJobMeta(job.name) && jobMeta(job.name).description" class="tw:text-sm tw:text-gray-500">
{{ jobMeta(job.name).description }}
</span>
</div>
<span
v-if="job.failed_reason"
class="tw:inline-flex tw:items-center tw:gap-1 tw:shrink-0 tw:px-2 tw:py-1 tw:rounded tw:text-sm tw:bg-red-100 tw:text-red-700 tw:cursor-default"
role="img"
:aria-label="'Ошибка: ' + job.failed_reason"
v-tooltip.top="errorTooltip(job)"
>
<i class="fa fa-exclamation-circle" aria-hidden="true"/>
Ошибка
</span>
<div
v-else
class="tw:flex tw:flex-col tw:gap-0.5 tw:shrink-0 tw:items-end"
>
<span
class="tw:inline-flex tw:items-center tw:shrink-0 tw:px-2 tw:py-1 tw:rounded tw:text-sm tw:bg-green-100 tw:text-green-800 tw:cursor-default"
v-tooltip.top="'Дата последнего успешного запуска'"
>
{{ formatLastRun(job.last_success_at) }}
</span>
<span
v-if="formatDuration(job.last_duration_seconds)"
class="tw:text-xs tw:text-gray-500"
>
Время выполнения: {{ formatDuration(job.last_duration_seconds) }}
</span>
</div>
<div class="tw:shrink-0 tw:w-[14rem]">
<CronExpressionSelect
:model-value="job.cron_expression"
@update:model-value="job.cron_expression = $event"
/>
</div>
</div>
<div v-if="!scheduledJobs.length" class="tw:text-gray-500 tw:py-4 tw:px-3">
Нет запланированных задач
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useSettingsStore } from '@/stores/settings.js';
import ToggleSwitch from 'primevue/toggleswitch';
import CronExpressionSelect from '@/components/CronExpressionSelect.vue';
const settings = useSettingsStore();
/** Человекочитаемое имя и описание задач (ключ — job.name с бэкенда) */
const JOB_META = {
megapay_pulse_send_events: {
friendlyName: 'Отправка данных в MegaPay Pulse',
description: 'Отправка данных телеметрии о действиях в MegaPay. Требуется для сбора метрик по рассылкам и кампаниям, сделанных через сервис MegaPay Pulse',
},
};
function hasJobMeta(jobName) {
return jobName in JOB_META;
}
function jobMeta(jobName) {
return JOB_META[jobName];
}
const scheduledJobs = computed(() => settings.items.scheduled_jobs ?? []);
function formatLastRun(value) {
if (!value) return '—';
const d = typeof value === 'string' ? new Date(value.replace(' ', 'T')) : new Date(value);
return Number.isNaN(d.getTime()) ? value : d.toLocaleString('ru-RU');
}
function formatDuration(seconds) {
if (seconds == null || seconds === '' || Number(seconds) <= 0) return '';
const n = Number(seconds);
if (Number.isNaN(n)) return '';
if (n < 1) return 'менее 1с';
return `${n % 1 ? n.toFixed(1) : Math.round(n)}с`;
}
function errorTooltip(job) {
const dateStr = formatLastRun(job.failed_at);
return `${dateStr}\n${job.failed_reason ?? ''}`.trim();
}
function onJobToggle(job, isEnabled) {
job.is_enabled = isEnabled ? 1 : 0;
}
</script>

View File

@@ -0,0 +1,27 @@
<template>
<SettingsItem :label="label">
<template #default>
<Switcher v-model="model"/>
</template>
<template #help>
<slot></slot>
</template>
</SettingsItem>
</template>
<script setup>
import Switcher from "@/components/Switcher.vue";
import SettingsItem from "@/components/SettingsItem.vue";
const model = defineModel();
const props = defineProps({
label: {
type: String,
default: '',
},
});
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,116 @@
<template>
<SettingsItem :label="label">
<template #default>
<input
ref="searchInput"
type="text"
placeholder="Начните вводить название категории..."
class="form-control"
autocomplete="off"
/>
<div class="well well-sm tw:h-90 tw:overflow-auto">
<div v-if="isLoading">
<i class="fa fa-spinner fa-spin"></i>
Загрузка списка категорий...
</div>
<div v-else v-for="(product, index) in selectedProducts"
class="tw:flex tw:items-center tw:mb-1">
<button
@click.prevent="removeItem(index)"
class="btn btn-xs btn-danger"
>
<i class="fa fa-minus-circle"></i>
</button>
<div class="tw:ml-3">{{ product.name }}</div>
</div>
</div>
</template>
<template #help>
<slot></slot>
</template>
</SettingsItem>
</template>
<script setup>
import SettingsItem from "@/components/SettingsItem.vue";
import {nextTick, onMounted, ref, watch} from "vue";
import {apiPost} from "@/utils/http.js";
const searchInput = ref(null);
const isLoading = ref(false);
const props = defineProps({
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: ''
},
readonly: {
type: Boolean,
default: false,
},
rows: {
type: Number,
default: 5,
},
});
const model = defineModel();
function removeItem(index) {
model.value.splice(index, 1);
}
const selectedProducts = ref([]);
watch(
model.value,
async (ids) => {
if (!ids?.length) {
selectedProducts.value = [];
return;
}
try {
isLoading.value = true;
const response = await apiPost('getCategoriesById', {
category_ids: ids,
});
selectedProducts.value = response.data.data;
} catch (err) {
console.error(err);
} finally {
isLoading.value = false;
}
},
{immediate: true}
);
onMounted(() => {
nextTick(() => {
if (searchInput.value) {
$(searchInput.value).autocomplete({
source: function (request, response) {
$.ajax({
url: `/admin/index.php?route=catalog/category/autocomplete&user_token=${window.MegaPay.user_token}&filter_name=${encodeURIComponent(request)}`,
dataType: 'json',
success: function (json) {
response($.map(json, function (item) {
return {
label: item['name'],
value: Number(item['category_id']),
};
}));
}
});
},
select: function (item) {
model.value.push(item['value']);
}
});
}
});
});
</script>

View File

@@ -0,0 +1,25 @@
<template>
<SettingsItem :label="label">
<template #default>
<OcImagePicker v-model="model" class="tw:w-30"/>
</template>
<template #help><slot></slot></template>
</SettingsItem>
</template>
<script setup>
import OcImagePicker from "@/components/OcImagePicker.vue";
import SettingsItem from "@/components/SettingsItem.vue";
const model = defineModel();
const props = defineProps({
label: {
type: String,
default: '',
},
});
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,72 @@
<template>
<SettingsItem :label="label">
<template #default>
<InputGroup v-if="allowCopy && isSupported">
<Button
:key="copied ? 'copied' : 'copy'"
:icon="copied ? 'fa fa-check' : 'fa fa-copy'"
severity="secondary"
v-tooltip.top="{ value: copied ? 'Скопировано' : 'Скопировать' }"
@click="copyToClipboard"
/>
<InputText
:type="type"
v-model="model"
class="form-control"
:placeholder="placeholder"
:readonly="readonly"
/>
</InputGroup>
<InputText
v-else
:type="type"
v-model="model"
class="form-control"
:placeholder="placeholder"
:readonly="readonly"
/>
</template>
<template #help>
<slot></slot>
</template>
</SettingsItem>
</template>
<script setup>
import SettingsItem from "@/components/SettingsItem.vue";
import InputText from 'primevue/inputtext';
import InputGroup from 'primevue/inputgroup';
import Button from 'primevue/button';
import { useClipboard } from '@vueuse/core';
const props = defineProps({
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: 'Введите значение'
},
type: {
type: String,
default: 'text',
},
readonly: {
type: Boolean,
default: false,
},
allowCopy: {
type: Boolean,
default: false,
},
});
const model = defineModel();
const { copy, copied, isSupported } = useClipboard({ source: model })
function copyToClipboard() {
copy();
}
</script>

View File

@@ -0,0 +1,116 @@
<template>
<SettingsItem :label="label">
<template #default>
<input
ref="productsInput"
type="text"
placeholder="Начните вводить название товара..."
class="form-control"
autocomplete="off"
/>
<div class="well well-sm tw:h-90 tw:overflow-auto">
<div v-if="isLoading">
<i class="fa fa-spinner fa-spin"></i>
Загрузка списка товаров...
</div>
<div v-else v-for="(product, index) in selectedProducts"
class="tw:flex tw:items-center tw:mb-1">
<button
@click.prevent="removeItem(index)"
class="btn btn-xs btn-danger"
>
<i class="fa fa-minus-circle"></i>
</button>
<div class="tw:ml-3">{{ product.name }}</div>
</div>
</div>
</template>
<template #help>
<slot></slot>
</template>
</SettingsItem>
</template>
<script setup>
import SettingsItem from "@/components/SettingsItem.vue";
import {nextTick, onMounted, ref, watch} from "vue";
import {apiPost} from "@/utils/http.js";
const productsInput = ref(null);
const isLoading = ref(false);
const props = defineProps({
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: ''
},
readonly: {
type: Boolean,
default: false,
},
rows: {
type: Number,
default: 5,
},
});
const model = defineModel();
function removeItem(index) {
model.value.splice(index, 1);
}
const selectedProducts = ref([]);
watch(
model.value,
async (ids) => {
if (!ids?.length) {
selectedProducts.value = [];
return;
}
try {
isLoading.value = true;
const response = await apiPost('getProductsById', {
product_ids: ids,
});
selectedProducts.value = response.data.data;
} catch (err) {
console.error(err);
} finally {
isLoading.value = false;
}
},
{immediate: true}
);
onMounted(() => {
nextTick(() => {
if (productsInput.value) {
$(productsInput.value).autocomplete({
source: function (request, response) {
$.ajax({
url: `/admin/index.php?route=catalog/product/autocomplete&user_token=${window.MegaPay.user_token}&filter_name=${encodeURIComponent(request)}`,
dataType: 'json',
success: function (json) {
response($.map(json, function (item) {
return {
label: item['name'],
value: Number(item['product_id']),
};
}));
}
});
},
select: function (item) {
model.value.push(item['value']);
}
});
}
});
});
</script>

View File

@@ -0,0 +1,48 @@
<template>
<SettingsItem :label="label">
<template #default>
<select class="form-control" v-model="model">
<option
v-for="(value, key) in items"
:value="normalizeOptionValue(key)"
:key="key"
>
{{ value }}
</option>
</select>
</template>
<template #help>
<slot/>
</template>
</SettingsItem>
</template>
<script setup>
import SettingsItem from "@/components/SettingsItem.vue";
const model = defineModel();
const props = defineProps({
items: {
type: Object,
default: {},
},
label: {
type: String,
default: '',
},
});
// Преобразуем числовые ключи обратно в Number, чтобы v-model не получал строки
const normalizeOptionValue = (key) => {
if (typeof key === 'number') {
return key;
}
const parsed = Number(key);
return Number.isNaN(parsed) ? key : parsed;
};
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,41 @@
<template>
<SettingsItem :label="label">
<template #default>
<Textarea
v-model="model"
class="form-control"
:placeholder="placeholder"
:readonly="readonly"
:rows="rows"
/>
</template>
<template #help>
<slot></slot>
</template>
</SettingsItem>
</template>
<script setup>
import SettingsItem from "@/components/SettingsItem.vue";
import Textarea from 'primevue/textarea';
const props = defineProps({
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: ''
},
readonly: {
type: Boolean,
default: false,
},
rows: {
type: Number,
default: 5,
},
});
const model = defineModel();
</script>

View File

@@ -0,0 +1,145 @@
<template>
<SettingsItem :label="label">
<template #default>
<div class="tw:flex tw:w-full">
<span class="tw:flex">
<button
class="btn btn-primary tw:whitespace-nowrap"
type="button"
@click="validateBotToken"
:disabled="isLoading || ! settings.items.telegram.bot_token"
:class="{
'tw:opacity-60 tw:cursor-not-allowed': isLoading
}"
>
<i
:class="isLoading ? 'fa fa-spinner fa-spin tw:mr-1' : 'fa fa-refresh tw:mr-1'"
></i>
{{ isLoading ? 'Проверяю...' : 'Проверить Bot Token' }}
</button>
</span>
<input
type="text"
v-model="model"
@input="handleInput"
@blur="validateBotToken"
placeholder="Введите токен от Telegram бота"
class="form-control"
:readonly="isLoading"
/>
</div>
<div
v-if="validationStatus"
class="alert"
:class="validationStatusClass"
>
{{ validationStatus }}
</div>
</template>
<template #help>
Подробная инструкция доступна в
<a href="https://megapay-labs.github.io/docs/telegram/telegram/#%D1%81%D0%BE%D0%B7%D0%B4%D0%B0%D0%BD%D0%B8%D0%B5-%D0%B1%D0%BE%D1%82%D0%B0" target="_blank">документации
<i class="fa fa-external-link"></i>
</a>.
</template>
</SettingsItem>
</template>
<script setup>
import {useSettingsStore} from "@/stores/settings.js";
import {ref, computed} from "vue";
import SettingsItem from "@/components/SettingsItem.vue";
import {apiPost} from "@/utils/http.js";
const model = defineModel();
const settings = useSettingsStore();
const validationStatus = ref(null);
const isLoading = ref(false);
const props = defineProps({
label: {
type: String,
default: '',
},
});
const validationStatusClass = computed(() => {
if (!validationStatus.value) return '';
if (validationStatus.value.startsWith('✅')) {
return 'alert-success';
}
if (validationStatus.value.startsWith('❌')) {
return 'alert-danger';
}
return 'alert-info';
});
function handleInput(event) {
model.value = event.target.value;
// Сбрасываем статус валидации при изменении токена
if (validationStatus.value) {
validationStatus.value = null;
}
}
async function validateBotToken() {
const botToken = model.value?.trim() || '';
// Валидация пустого токена
if (botToken.length === 0) {
validationStatus.value = '❌ Введите Bot Token!';
return;
}
// Сбрасываем предыдущий статус
validationStatus.value = null;
isLoading.value = true;
try {
const result = await apiPost('configureBotToken', { botToken });
if (!result.success) {
// Обработка ошибок
if (result.status === 422) {
validationStatus.value = `❌ Ошибка: ${result.error || 'Неверный токен'}`;
} else {
validationStatus.value = `❌ Ошибка проверки BotToken: ${result.error || 'Неизвестная ошибка'}`;
}
return;
}
const response = result.data;
// Проверка наличия обязательных полей в ответе
if (!response?.id) {
validationStatus.value = '❌ Ошибка: bot token не найден в ответе сервера.';
console.error('Неожиданный формат ответа:', response);
return;
}
// Успешная валидация
const username = response.username ? `@${response.username}` : 'не указан';
const webhookUrl = response.webhook_url || 'не настроен';
validationStatus.value = `✅ Бот: ${username} (id: ${response.id}) webhook: ${webhookUrl}`;
// Обновляем токен в store, если нужно (на случай если сервер что-то изменил)
if (response.bot_token && response.bot_token !== botToken) {
model.value = response.bot_token;
}
} catch (error) {
console.error('Ошибка при валидации BotToken:', error);
validationStatus.value = '❌ Ошибка проверки BotToken. Проверьте подключение к серверу.';
} finally {
isLoading.value = false;
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,190 @@
<template>
<SettingsItem :label="label">
<template #default>
<template v-if="settings.items.telegram.bot_token">
<div class="tw:flex tw:w-full">
<span class="tw:flex">
<button
class="btn btn-primary tw:whitespace-nowrap"
type="button"
@click="getChatId"
:disabled="isLoading || !settings.items.telegram.bot_token"
:class="{
'tw:opacity-60 tw:cursor-not-allowed': isLoading
}"
>
<i
:class="isLoading ? 'fa fa-spinner fa-spin tw:mr-1' : 'fa fa-refresh tw:mr-1'"
></i>
{{ isLoading ? 'Получаю...' : 'Получить Chat ID' }}
</button>
</span>
<input
type="text"
v-model="model"
@input="handleInput"
:placeholder="placeholder"
class="form-control"
:readonly="isLoading"
/>
</div>
<div
v-if="statusMessage"
class="alert"
:class="statusMessageClass"
>
{{ statusMessage }}
</div>
<button
class="btn btn-link btn-xs"
type="button"
data-toggle="collapse"
:data-target="`#${collapseId}`"
aria-expanded="false"
:aria-controls="collapseId"
>
Инструкция как получить ChatID.
</button>
<div class="collapse" :id="collapseId">
<div class="well">
<p class="text-primary">Как получить Chat ID</p>
<ol>
<li>Убедитесь, что Telegram Bot Token введён выше.</li>
<li>Откройте вашего бота в Telegram и отправьте ему кодовое слово: <code>opencart_get_chatid</code>. Важно отправить именно такое сообщение, иначе не сработает.</li>
<li>Вернитесь сюда и нажмите кнопку «Получить Chat ID» скрипт автоматически подставит его в поле ниже.</li>
</ol>
</div>
</div>
</template>
<div v-else class="alert alert-warning">
<strong>BotToken</strong> не указан. Пожалуйста, введите корректный BotToken. После этого здесь станет доступна настройка ChatID.
</div>
</template>
<template #help>
Идентификатор Telegram-чата, куда будут отправляться уведомления о новых заказах. Если оставить поле пустым, уведомления отправляться не будут.
</template>
</SettingsItem>
</template>
<script setup>
import {useSettingsStore} from "@/stores/settings.js";
import {ref, computed, useId} from "vue";
import SettingsItem from "@/components/SettingsItem.vue";
import {apiGet} from "@/utils/http.js";
const model = defineModel();
const settings = useSettingsStore();
const statusMessage = ref(null);
const isLoading = ref(false);
const collapseId = useId();
const parseChatId = (value) => {
if (value === '' || value === null || value === undefined) return null;
const normalized = String(value).trim();
if (!/^-?\d+$/.test(normalized)) return null;
const parsed = Number.parseInt(normalized, 10);
return Number.isFinite(parsed) ? parsed : null;
};
const props = defineProps({
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: 'Chat ID будет получен автоматически',
},
});
if (typeof model.value === 'string') {
model.value = parseChatId(model.value);
}
const statusMessageClass = computed(() => {
if (!statusMessage.value) return '';
if (statusMessage.value.startsWith('✅')) {
return 'alert-success';
}
if (statusMessage.value.startsWith('❌')) {
return 'alert-danger';
}
return 'alert-info';
});
function handleInput(event) {
model.value = parseChatId(event.target.value);
// Сбрасываем статус сообщения при изменении значения
if (statusMessage.value) {
statusMessage.value = null;
}
}
async function getChatId() {
// Проверка наличия bot_token
if (!settings.items.telegram.bot_token?.trim()) {
alert('Сначала введите Telegram Bot Token!');
return;
}
// Сбрасываем предыдущее сообщение
statusMessage.value = null;
isLoading.value = true;
try {
const response = await apiGet('getChatId');
if (!response.success) {
// Обработка ошибок
const errorMessage = response.data?.message || response.error || 'Неизвестная ошибка';
if (response.status === 422) {
statusMessage.value = `${errorMessage}`;
} else {
statusMessage.value = `❌ Ошибка получения Chat ID: ${errorMessage}`;
}
return;
}
// Проверка наличия chat_id в ответе
if (!response.data?.chat_id) {
statusMessage.value = '❌ Ошибка: Chat ID не найден в ответе сервера.';
console.error('Неожиданный формат ответа:', response);
return;
}
const parsedChatId = parseChatId(response.data.chat_id);
if (parsedChatId === null) {
statusMessage.value = '❌ Ошибка: Chat ID вернулся в некорректном формате.';
console.error('Некорректный Chat ID в ответе:', response);
return;
}
model.value = parsedChatId;
statusMessage.value = '✅ ChatID успешно получен и подставлен в поле. Не забудьте сохранить настройки!';
} catch (error) {
console.error('Ошибка при получении Chat ID:', error);
statusMessage.value = '❌ Ошибка получения Chat ID. Проверьте подключение к серверу.';
} finally {
isLoading.value = false;
}
}
</script>
<style scoped>
code {
background-color: #f5f5f5;
padding: 2px 4px;
border-radius: 3px;
font-family: monospace;
font-size: 0.9em;
}
</style>

View File

@@ -0,0 +1,165 @@
<template>
<SettingsItem :label="label">
<template #default>
<div style="margin-bottom: 10px;">
<Codemirror
v-model="model"
:placeholder="placeholder"
:extensions="extensions"
/>
</div>
<div>
<button
class="btn btn-link"
type="button"
data-toggle="collapse"
:data-target="`#${collapseId}`"
aria-expanded="false"
:aria-controls="collapseId"
>
Документация
</button>
<button
type="button"
class="btn btn-primary btn-sm"
@click="sendTestMessage"
:disabled="isSending"
:class="{
'tw:opacity-60 tw:cursor-not-allowed': isSending
}"
>
<i :class="isSending ? 'fa fa-spinner fa-spin' : 'fa fa-envelope'"></i>
{{ isSending ? 'Отправляю...' : 'Отправить тестовое уведомление' }}
</button>
</div>
<div class="collapse" :id="collapseId" style="margin-top: 15px">
<div class="well">
<p>
Для формирования сообщения используется HTML разметка.
Telegram поддерживает только часть HTML тегов, которые описаны в их
<a href="https://core.telegram.org/bots/api#html-style" target="_blank">документации <i class="fa fa-external-link"></i></a>.
</p>
<p>Дополнительно к этому MegaPay добавляет переменные, которые вы можете использовать, чтобы сделать сообщения динамическими.</p>
<ul>
<li><code>{store_name}</code> название магазина</li>
<li><code>{order_id}</code> номер заказа</li>
<li><code>{customer}</code> имя и фамилия покупателя</li>
<li><code>{email}</code> email покупателя</li>
<li><code>{phone}</code> телефон</li>
<li><code>{comment}</code> комментарий к заказу</li>
<li><code>{address}</code> адрес доставки</li>
<li><code>{total}</code> сумма заказа</li>
<li><code>{ip}</code> IP покупателя</li>
<li><code>{created_at}</code> дата и время создания заказа</li>
</ul>
<p></p>
</div>
</div>
</template>
<template #help>
<slot></slot>
</template>
</SettingsItem>
</template>
<script setup>
import {useSettingsStore} from "@/stores/settings.js";
import {ref, toRaw, useId} from "vue";
import SettingsItem from "@/components/SettingsItem.vue";
import {apiPost} from "@/utils/http.js";
import {Codemirror} from "vue-codemirror";
import { html } from '@codemirror/lang-html';
import { oneDark } from '@codemirror/theme-one-dark';
const model = defineModel();
const settings = useSettingsStore();
const isSending = ref(false);
const collapseId = useId();
const extensions = [html(), oneDark];
const props = defineProps({
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: 'Введите шаблон сообщения',
},
rows: {
type: Number,
default: 5,
},
});
async function sendTestMessage() {
console.log(toRaw(settings.items.telegram));
const telegramToken = settings.items.telegram.bot_token?.trim();
if (!telegramToken) {
alert('Сначала введите Telegram Bot Token!');
return;
}
const chatId = settings.items.telegram.chat_id;
if (!chatId) {
alert('Сначала введите Chat ID!');
return;
}
const template = model.value?.trim();
if (!template) {
alert('Сначала задайте шаблон!');
return;
}
isSending.value = true;
try {
const result = await apiPost('testTgMessage', {
token: telegramToken,
chat_id: chatId,
template: template,
});
if (!result.success) {
const errorMessage = result.data?.message || result.error || 'Неизвестная ошибка';
alert(`Ошибка: ${errorMessage}`);
return;
}
const response = result.data;
alert(response.message || 'Уведомление успешно отправлено');
} catch (error) {
console.error('Ошибка при отправке тестового сообщения:', error);
alert('Ошибка при отправке тестового сообщения');
} finally {
isSending.value = false;
}
}
</script>
<style scoped>
code {
background-color: #f5f5f5;
padding: 2px 4px;
border-radius: 3px;
font-family: monospace;
font-size: 0.9em;
}
pre {
background-color: #f5f5f5;
padding: 10px;
border-radius: 3px;
font-family: monospace;
font-size: 0.9em;
overflow-x: auto;
}
</style>

View File

@@ -0,0 +1,31 @@
<template>
<ItemInput
:label="label"
type="text"
:readonly="true"
:modelValue="model"
:allowCopy="true"
>
Ссылка на сайт с MegaPay витриной, которую нужно указывать в настройках MiniApp в @BotFather.<br>
Подробная инструкция по настройке в
<a href="https://docs.megapay.pro/telegram/telegram/" target="_blank">
документации <i class="fa fa-external-link"></i>
</a>.
</ItemInput>
</template>
<script setup>
import ItemInput from "@/components/Settings/ItemInput.vue";
const props = defineProps({
label: {
type: String,
default: '',
},
});
const model = defineModel();
</script>
<style scoped lang="scss">
</style>

View File

@@ -0,0 +1,51 @@
<template>
<SettingsItem :label="label">
<template #default>
<SelectButton
:modelValue="model"
:options="options"
optionLabel="label"
optionValue="value"
:allowEmpty="false"
@update:modelValue="updateValue"
/>
</template>
<template #help>
<slot/>
</template>
</SettingsItem>
</template>
<script setup>
import {computed} from "vue";
import SettingsItem from "@/components/SettingsItem.vue";
import SelectButton from "primevue/selectbutton";
const model = defineModel();
const props = defineProps({
items: {
type: Object,
default: {},
},
label: {
type: String,
default: '',
},
});
const options = computed(() => {
return Object.entries(props.items).map(([value, label]) => ({
value,
label,
}));
});
function updateValue(newValue) {
model.value = newValue;
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,67 @@
<template>
<div class="form-group">
<div class="col-sm-2 tw:flex tw:flex-col tw:gap-1">
<label class="control-label" for="module_tgshop_status">
{{ label }}
</label>
<a
v-if="docHref"
:href="docHref"
target="_blank"
rel="noopener noreferrer"
class="tw:inline-flex tw:items-center tw:gap-1 tw:text-sm tw:text-gray-500 hover:tw:text-gray-700 tw:underline tw:decoration-dotted tw:decoration-1 tw:underline-offset-2 tw:w-fit tw:self-end"
>
<i class="fa fa-external-link tw:text-xs" aria-hidden="true"></i>
<span>Документация</span>
</a>
</div>
<div class="col-sm-10">
<slot name="default"></slot>
<div class="help-block">
<slot name="help"></slot>
</div>
<div v-if="hasExpandable">
<Button
:label="expandableLabel"
severity="info"
link
size="small"
@click="expanded = !expanded"
/>
<div v-show="expanded" class="tw:mt-2 tw:space-y-2">
<slot name="expandable"></slot>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, useSlots, computed } from 'vue';
import Button from 'primevue/button';
const props = defineProps({
label: {
type: String,
default: '',
},
/** Ссылка на документацию: отображается под label, открывается в новой вкладке */
docHref: {
type: String,
default: '',
},
/** Подпись кнопки раскрытия блока #expandable (по умолчанию «Подробнее») */
expandableLabel: {
type: String,
default: 'Подробнее',
},
});
const slots = useSlots();
const hasExpandable = computed(() => !!slots.expandable);
const expanded = ref(false);
</script>
<style scoped lang="scss">
</style>

View File

@@ -0,0 +1,66 @@
<template>
<div>
<input
type="search"
name="category"
:value="`${category?.name || ''}`"
placeholder="Начните вводить название категории..."
class="form-control"
ref="categoryRef"
/>
</div>
</template>
<script setup>
import {onMounted, onUnmounted, ref} from "vue";
const category = defineModel();
const categoryRef = ref(null);
onMounted(() => {
const input = categoryRef.value;
if (!input) {
return;
}
$(input).autocomplete({
'source': function (request, response) {
if ($(input).val().length === 0) {
$(input).val(null);
}
$.ajax({
url: `index.php?route=catalog/category/autocomplete&user_token=${window.MegaPay.user_token}&filter_name=` + encodeURIComponent(request),
dataType: 'json',
success: function (json) {
response($.map(json, function (item) {
return {
label: item['name'],
value: item['category_id']
}
}));
}
});
},
'select': function (item) {
category.value = {
category_id: Number(item['value']),
name: item['label'],
};
}
});
});
onUnmounted(() => {
const input = categoryRef.value;
if (!input) {
return;
}
$(input).autocomplete('destroy');
});
</script>
<style scoped lang="scss">
</style>

View File

@@ -0,0 +1,59 @@
<template>
<div>
<div>
<select v-model="link.type" class="form-control link-type-select" @change="link.value = null">
<option value="none">Нет ссылки</option>
<option value="category">Ссылка на категорию</option>
<option value="product">Ссылка на товар</option>
<option value="url">Внешняя ссылка</option>
</select>
</div>
<div v-if="link.type === 'url'" class="mt-10">
<input
:value="link.value?.url"
@input="setLink($event.target.value)"
type="text"
placeholder="https://example.com"
class="form-control"
/>
</div>
<div v-else-if="link.type === 'category'" class="mt-10">
<CategorySelect v-model="link.value"/>
</div>
<div v-else-if="link.type === 'product'" class="mt-10">
<ProductSelect v-model="link.value"/>
</div>
<div v-else-if="link.type === 'none'"></div>
<div v-else class="alert alert-danger">Не поддерживается: {{ link.type }}</div>
</div>
</template>
<script setup>
import CategorySelect from "@/components/Slider/CategorySelect.vue";
import ProductSelect from "@/components/Slider/ProductSelect.vue";
const link = defineModel();
function setLink(value) {
if (link.value?.value) {
link.value.value.url = value;
} else {
link.value.value = { url: value };
}
}
</script>
<style scoped>
.link-type-select {
cursor: pointer;
}
.mt-10 {
margin-top: 10px;
}
</style>

View File

@@ -0,0 +1,65 @@
<template>
<div>
<input
type="search"
:value="`${model?.name || ''}`"
placeholder="Начните вводить название товара..."
class="form-control"
ref="inputRef"
/>
</div>
</template>
<script setup>
import {onMounted, onUnmounted, ref} from "vue";
const model = defineModel();
const inputRef = ref(null);
onMounted(() => {
const input = inputRef.value;
if (!input) {
return;
}
$(input).autocomplete({
'source': function (request, response) {
if ($(input).val().length === 0) {
$(input).val(null);
}
$.ajax({
url: `index.php?route=catalog/product/autocomplete&user_token=${window.MegaPay.user_token}&filter_name=` + encodeURIComponent(request),
dataType: 'json',
success: function (json) {
response($.map(json, function (item) {
return {
label: item['name'],
value: item['product_id']
}
}));
}
});
},
'select': function (item) {
model.value = {
product_id: Number(item['value']),
name: item['label'],
};
}
});
});
onUnmounted(() => {
const input = inputRef.value;
if (!input) {
return;
}
$(input).autocomplete('destroy');
});
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,15 @@
<template>
<ToggleSwitch v-model="model" />
</template>
<script setup>
import ToggleSwitch from 'primevue/toggleswitch';
const model = defineModel({
default: false,
});
</script>
<style scoped lang="scss">
</style>

View File

@@ -0,0 +1,229 @@
<template>
<div class="tw:bg-surface-0 tw:dark:bg-surface-950 tw:px-6 tw:py-8 tw:md:px-12 tw:lg:px-20">
<div class="tw:flex tw:items-center tw:flex-col tw:lg:flex-row tw:lg:justify-between">
<div class="tw:flex tw:items-start tw:flex-col tw:lg:flex-row tw:gap-8">
<OcImagePicker v-model="settings.items.app.app_icon" class="tw:w-[6.42rem] tw:h-[6.42rem]"/>
<div class="tw:flex tw:flex-col tw:gap-4">
<div class="tw:flex tw:items-center">
<span class="tw:text-surface-900 tw:dark:text-surface-0 tw:font-bold tw:text-3xl">
{{ settings.items.app.app_name }}
</span>
<a
v-if="tgMe?.result?.first_name"
:href="`https://t.me/${tgMe?.result?.username}`"
class="tw:ml-2 tw:text-surface-900 tw:dark:text-surface-0 tw:text-xl">
@{{ tgMe?.result?.first_name }}
</a>
</div>
<div class="tw:flex tw:items-center tw:flex-wrap tw:gap-8">
<div>
<span
v-tooltip.top="'Общее количество заказов, сделанное через MegaPay за всё время.'"
class="tw:text-surface-500 tw:dark:text-surface-300"
>
Количество заказов
</span>
<div
class="tw:text-surface-700 tw:dark:text-surface-100 tw:mt-1 tw:text-sm tw:font-semibold"
>
{{ stats.items.orders_count ?? '-' }}
</div>
</div>
<div>
<span
v-tooltip.top="'Итоговая сумма заказов, сделанных через MegaPay за всё время.'"
class="tw:text-surface-500 tw:dark:text-surface-300"
>Общая сумма</span>
<div
class="tw:text-surface-700 tw:dark:text-surface-100 tw:mt-1 tw:text-sm tw:font-semibold">
{{ rub(stats.items.orders_total_amount ?? 0) }}
</div>
</div>
<div>
<span
v-tooltip.top="'Общее количество уникальных Telegram-посетителей, взаимодействовавших с магазином за всё время включая тех, кто просто заходил посмотреть, без оформления заказа.'"
class="tw:text-surface-500 tw:dark:text-surface-300">Кол-во посетителей</span>
<div
class="tw:text-surface-700 tw:dark:text-surface-100 tw:mt-1 tw:text-sm tw:font-semibold">
<RouterLink to="/customers">{{ stats.items.customers_count ?? 0 }}</RouterLink>
</div>
</div>
<div>
<span
v-tooltip.top="'Текущий статус магазина'"
class="tw:text-surface-500 tw:dark:text-surface-300">Статус магазина</span>
<div
class="tw:text-surface-700 tw:dark:text-surface-100 tw:mt-1 tw:text-sm tw:font-semibold">
<div v-if="settings.items.app.app_enabled" class="tw:flex tw:items-center">
<div class="tw:h-2 tw:w-2 tw:rounded-full tw:bg-green-400 tw:flex tw:mr-2">
<span
class="tw:inline-flex tw:h-full tw:w-full tw:animate-ping tw:rounded-full tw:bg-green-400 tw:opacity-75"></span>
</div>
<div>Online</div>
</div>
<div v-else
class="tw:text-surface-700 tw:dark:text-surface-100 tw:mt-1 tw:text-sm tw:font-semibold">
<div class="tw:flex tw:items-center">
<div class="tw:h-2 tw:w-2 tw:rounded-full tw:bg-red-400 tw:flex tw:mr-2">
<span
class="tw:inline-flex tw:h-full tw:w-full tw:animate-ping tw:rounded-full tw:bg-red-400 tw:opacity-75"></span>
</div>
<div>Offline</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="tw:mt-6 tw:lg:mt-0 tw:flex tw:items-center tw:gap-4">
<ButtonGroup>
<ResetCacheBtn/>
<Button
icon="fa fa-list"
v-tooltip.top="'Журнал событий'"
@click="showLogsDrawer = true"
/>
<Button
icon="fa fa-info-circle"
v-tooltip.top="'Системная информация'"
@click="showSystemInfoDrawer = true"
/>
</ButtonGroup>
<ButtonGroup>
<Button
icon="fa fa-play"
v-tooltip.top="(tgMe?.result?.has_main_web_app !== true) ? 'Вы не привязали Telegram Mini App к боту.' : 'Открыть Telegram магазин'"
as="a"
target="_blank"
:href="`https://t.me/${tgMe?.result?.username}?startapp`"
/>
<Button
icon="fa fa-book"
v-tooltip.top="'Документация по модулю MegaPay'"
as="a"
target="_blank"
href="https://megapay-labs.github.io/docs/"
/>
<Button
icon="fa fa-group"
v-tooltip.top="'Официальная Telegram группа модуля MegaPay'"
as="a"
target="_blank"
href="https://t.me/ocstore3"
/>
</ButtonGroup>
</div>
</div>
<Drawer
v-model:visible="showLogsDrawer"
header="Журнал событий"
position="right"
:baseZIndex="1000"
class="tw:!w-full tw:md:!w-1/2"
>
<LogsViewer/>
</Drawer>
<Drawer
v-model:visible="showSystemInfoDrawer"
header="Системная информация"
position="right"
:baseZIndex="1000"
class="tw:!w-full tw:md:!w-1/2"
>
<div class="tw:flex tw:flex-col tw:gap-4 tw:h-full">
<div class="tw:flex tw:justify-end">
<Button
label="Скопировать"
icon="fa fa-copy"
@click="copySystemInfo"
:disabled="!systemInfo"
/>
</div>
<Textarea
v-model="systemInfo"
readonly
class="tw:w-full tw:h-full tw:font-mono tw:text-sm"
style="font-family: monospace;"
/>
</div>
</Drawer>
</div>
</template>
<script setup>
import {useSettingsStore} from "@/stores/settings.js";
import {useStatsStore} from "@/stores/stats.js";
import {onMounted, ref, watch} from "vue";
import OcImagePicker from "@/components/OcImagePicker.vue";
import {apiGet} from "@/utils/http.js";
import ResetCacheBtn from "@/components/Form/ResetCacheBtn.vue";
import {Button, ButtonGroup, Drawer} from "primevue";
import Textarea from 'primevue/textarea';
import {rub} from "@/utils/helpers.js";
import LogsViewer from "@/components/LogsViewer.vue";
import {useToast} from "primevue/usetoast";
const settings = useSettingsStore();
const stats = useStatsStore();
const toast = useToast();
const tgMe = ref(null);
const showLogsDrawer = ref(false);
const showSystemInfoDrawer = ref(false);
const systemInfo = ref('');
const fetchSystemInfo = async () => {
try {
const response = await apiGet('getSystemInfo');
if (response.success) {
systemInfo.value = response.data;
} else {
systemInfo.value = 'Ошибка при получении системной информации: ' + (response.error || 'Unknown error');
}
} catch (error) {
systemInfo.value = 'Ошибка при получении системной информации: ' + (error.message || 'Unknown error');
}
};
const copySystemInfo = async () => {
if (!systemInfo.value) {
return;
}
try {
await navigator.clipboard.writeText(systemInfo.value);
toast.add({
severity: 'success',
summary: 'Скопировано',
detail: 'Системная информация скопирована в буфер обмена',
life: 3000
});
} catch (error) {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: 'Не удалось скопировать информацию',
life: 3000
});
}
};
watch(showSystemInfoDrawer, (newValue) => {
if (newValue && !systemInfo.value) {
fetchSystemInfo();
}
});
onMounted(async () => {
await stats.fetchStats();
const response = await apiGet('tgGetMe');
tgMe.value = response.data;
});
</script>
<style scoped lang="scss">
</style>