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
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:
48
frontend/admin/src/components/CronExpressionSelect.vue
Normal file
48
frontend/admin/src/components/CronExpressionSelect.vue
Normal 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>
|
||||
104
frontend/admin/src/components/CronJobOrgUrlField.vue
Normal file
104
frontend/admin/src/components/CronJobOrgUrlField.vue
Normal 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>
|
||||
52
frontend/admin/src/components/Form/CategoryLabel.vue
Normal file
52
frontend/admin/src/components/Form/CategoryLabel.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<span>
|
||||
<slot name="default" :value="selectedValue">
|
||||
{{ selectedValue?.label || 'Не выбрана' }}
|
||||
</slot>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useAutocompleteStore} from "@/stores/autocomplete.js";
|
||||
import {computed, onMounted, ref} from "vue";
|
||||
|
||||
const autocomplete = useAutocompleteStore();
|
||||
|
||||
const isLoading = ref(false);
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
function findNodeByKey(nodes, keyToFind) {
|
||||
for (const node of nodes) {
|
||||
if (node.key === keyToFind) {
|
||||
return node;
|
||||
}
|
||||
if (node.children?.length) {
|
||||
const found = findNodeByKey(node.children, keyToFind);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedValue = computed(() => {
|
||||
const id = props.id;
|
||||
if (!id) return null;
|
||||
|
||||
const node = findNodeByKey(autocomplete.categories || [], id);
|
||||
if (!node) return null;
|
||||
|
||||
return node;
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
isLoading.value = true;
|
||||
await autocomplete.fetchCategories();
|
||||
isLoading.value = false;
|
||||
});
|
||||
</script>
|
||||
50
frontend/admin/src/components/Form/CategorySelect.vue
Normal file
50
frontend/admin/src/components/Form/CategorySelect.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<TreeSelect
|
||||
:modelValue="selectedValue"
|
||||
@update:modelValue="setNewValue"
|
||||
filter
|
||||
filterMode="lenient"
|
||||
:options="autocomplete.categories"
|
||||
:disabled="disabled"
|
||||
:loading="isLoading"
|
||||
:placeholder="placeholder"
|
||||
class="md:w-80 w-full"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import TreeSelect from "primevue/treeselect";
|
||||
import {useAutocompleteStore} from "@/stores/autocomplete.js";
|
||||
import {computed, onMounted, ref} from "vue";
|
||||
|
||||
const autocomplete = useAutocompleteStore();
|
||||
|
||||
const isLoading = ref(false);
|
||||
const model = defineModel();
|
||||
|
||||
const props = defineProps({
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const selectedValue = computed(() => {
|
||||
const id = model.value;
|
||||
return id ? {[String(id)]: true} : null;
|
||||
});
|
||||
|
||||
function setNewValue(event) {
|
||||
model.value = Number(Object.keys(event)[0]);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
isLoading.value = true;
|
||||
await autocomplete.fetchCategories();
|
||||
isLoading.value = false;
|
||||
});
|
||||
</script>
|
||||
40
frontend/admin/src/components/Form/ResetCacheBtn.vue
Normal file
40
frontend/admin/src/components/Form/ResetCacheBtn.vue
Normal 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>
|
||||
70
frontend/admin/src/components/FormBuilder/CodeEditor.vue
Normal file
70
frontend/admin/src/components/FormBuilder/CodeEditor.vue
Normal 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>
|
||||
376
frontend/admin/src/components/FormBuilder/FieldSettings.vue
Normal file
376
frontend/admin/src/components/FormBuilder/FieldSettings.vue
Normal 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>
|
||||
54
frontend/admin/src/components/FormBuilder/FieldsPanel.vue
Normal file
54
frontend/admin/src/components/FormBuilder/FieldsPanel.vue
Normal 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>
|
||||
476
frontend/admin/src/components/FormBuilder/FormBuilder.vue
Normal file
476
frontend/admin/src/components/FormBuilder/FormBuilder.vue
Normal 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>
|
||||
144
frontend/admin/src/components/FormBuilder/FormCanvas.vue
Normal file
144
frontend/admin/src/components/FormBuilder/FormCanvas.vue
Normal 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>
|
||||
57
frontend/admin/src/components/FormBuilder/FormRenderer.vue
Normal file
57
frontend/admin/src/components/FormBuilder/FormRenderer.vue
Normal 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>
|
||||
124
frontend/admin/src/components/FormBuilder/IconPicker.vue
Normal file
124
frontend/admin/src/components/FormBuilder/IconPicker.vue
Normal 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>
|
||||
|
||||
@@ -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, // Экспортируем метод установки ошибки
|
||||
};
|
||||
}
|
||||
@@ -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: '',
|
||||
}
|
||||
},
|
||||
];
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
191
frontend/admin/src/components/LogsViewer.vue
Normal file
191
frontend/admin/src/components/LogsViewer.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
54
frontend/admin/src/components/OcImagePicker.vue
Normal file
54
frontend/admin/src/components/OcImagePicker.vue
Normal 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>
|
||||
133
frontend/admin/src/components/RichTextEditor.vue
Normal file
133
frontend/admin/src/components/RichTextEditor.vue
Normal 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>
|
||||
|
||||
108
frontend/admin/src/components/ScheduledJobsList.vue
Normal file
108
frontend/admin/src/components/ScheduledJobsList.vue
Normal 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>
|
||||
27
frontend/admin/src/components/Settings/ItemBool.vue
Normal file
27
frontend/admin/src/components/Settings/ItemBool.vue
Normal 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>
|
||||
116
frontend/admin/src/components/Settings/ItemCategoriesSelect.vue
Normal file
116
frontend/admin/src/components/Settings/ItemCategoriesSelect.vue
Normal 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>
|
||||
25
frontend/admin/src/components/Settings/ItemImage.vue
Normal file
25
frontend/admin/src/components/Settings/ItemImage.vue
Normal 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>
|
||||
72
frontend/admin/src/components/Settings/ItemInput.vue
Normal file
72
frontend/admin/src/components/Settings/ItemInput.vue
Normal 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>
|
||||
116
frontend/admin/src/components/Settings/ItemProductsSelect.vue
Normal file
116
frontend/admin/src/components/Settings/ItemProductsSelect.vue
Normal 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>
|
||||
48
frontend/admin/src/components/Settings/ItemSelect.vue
Normal file
48
frontend/admin/src/components/Settings/ItemSelect.vue
Normal 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>
|
||||
41
frontend/admin/src/components/Settings/ItemTextarea.vue
Normal file
41
frontend/admin/src/components/Settings/ItemTextarea.vue
Normal 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>
|
||||
145
frontend/admin/src/components/Settings/ItemTgBotToken.vue
Normal file
145
frontend/admin/src/components/Settings/ItemTgBotToken.vue
Normal 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>
|
||||
190
frontend/admin/src/components/Settings/ItemTgChatID.vue
Normal file
190
frontend/admin/src/components/Settings/ItemTgChatID.vue
Normal 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>
|
||||
|
||||
165
frontend/admin/src/components/Settings/ItemTgMessageTemplate.vue
Normal file
165
frontend/admin/src/components/Settings/ItemTgMessageTemplate.vue
Normal 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>
|
||||
|
||||
31
frontend/admin/src/components/Settings/ItemTgMiniAppLink.vue
Normal file
31
frontend/admin/src/components/Settings/ItemTgMiniAppLink.vue
Normal 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>
|
||||
51
frontend/admin/src/components/Settings/ItemToggleButton.vue
Normal file
51
frontend/admin/src/components/Settings/ItemToggleButton.vue
Normal 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>
|
||||
|
||||
67
frontend/admin/src/components/SettingsItem.vue
Normal file
67
frontend/admin/src/components/SettingsItem.vue
Normal 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>
|
||||
66
frontend/admin/src/components/Slider/CategorySelect.vue
Normal file
66
frontend/admin/src/components/Slider/CategorySelect.vue
Normal 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>
|
||||
59
frontend/admin/src/components/Slider/LinkSelector.vue
Normal file
59
frontend/admin/src/components/Slider/LinkSelector.vue
Normal 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>
|
||||
65
frontend/admin/src/components/Slider/ProductSelect.vue
Normal file
65
frontend/admin/src/components/Slider/ProductSelect.vue
Normal 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>
|
||||
15
frontend/admin/src/components/Switcher.vue
Normal file
15
frontend/admin/src/components/Switcher.vue
Normal 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>
|
||||
229
frontend/admin/src/components/TopLead.vue
Normal file
229
frontend/admin/src/components/TopLead.vue
Normal 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>
|
||||
Reference in New Issue
Block a user