feat: add FormKit framework support and update dependencies

- Add `telecart_forms` table migration and default checkout form seeder
- Implement `FormsHandler` to fetch form schemas
- Update `OrderCreateService` to handle custom fields in order comments
- Add `update` method to QueryBuilder and Grammar
- Add `Arr::except` helper
- Update composer dependencies (Carbon, Symfony, PHPUnit, etc.)
- Improve `MigratorService` error handling
- Add unit tests for new functionality
This commit is contained in:
2025-11-15 01:23:17 +03:00
committed by Nikita Kiselev
parent ae9771dec4
commit 6a59dcc0c9
69 changed files with 12529 additions and 416 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -16,10 +16,16 @@
"format": "prettier --write src/"
},
"dependencies": {
"@codemirror/lang-json": "^6.0.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@formkit/drag-and-drop": "^0.5.3",
"@formkit/i18n": "^1.6.9",
"@formkit/vue": "^1.6.9",
"@primeuix/themes": "^1.2.5",
"@tailwindcss/vite": "^4.1.16",
"@vueuse/core": "^14.0.0",
"axios": "^1.13.1",
"codemirror": "^6.0.2",
"daisyui": "^5.4.2",
"js-md5": "^0.8.3",
"mitt": "^3.0.1",
@@ -27,11 +33,13 @@
"primevue": "^4.4.1",
"tailwindcss": "^4.1.16",
"vue": "^3.5.22",
"vue-codemirror": "^6.1.1",
"vue-router": "^4.6.3",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@eslint/js": "^9.37.0",
"@formkit/icons": "^1.6.9",
"@prettier/plugin-oxc": "^0.0.4",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/eslint-config-prettier": "^10.2.0",

View File

@@ -30,6 +30,10 @@
<RouterLink :to="{name: 'mainpage'}">Главная страница</RouterLink>
</li>
<li :class="{active: route.name === 'formbuilder'}">
<RouterLink :to="{name: 'formbuilder'}">Форма заказа</RouterLink>
</li>
<li :class="{active: route.name === 'logs'}">
<RouterLink :to="{name: 'logs'}">Журнал событий</RouterLink>
</li>

View File

@@ -46,3 +46,23 @@ legend.p-fieldset-legend {
.telecart-admin-app {
color: var(--color-slate-700);
}
.blueprint-bg {
background-color: #efefef;
opacity: 0.7;
background-image: radial-gradient(#989898 0.65px, #efefef 0.65px);
background-size: 13px 13px;
}
ul.formkit-options {
padding: 0;
margin-bottom: 0;
list-style: none;
}
ul.formkit-options label {
display: inline-flex;
}
ul.formkit-options input[type="radio"] {
position: absolute;
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,320 @@
<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 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 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">
<FormRenderer
:schema="formSchema"
submit-label="Отправить форму"
@submit="handleFormSubmit"
/>
</div>
</div>
</template>
</div>
</div>
</template>
<script setup>
import { ref, provide, computed, onMounted } 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';
const formFields = defineModel({
type: Array,
default: () => []
});
const isCustom = defineModel('isCustom', {
type: Boolean,
default: false
});
const activeMode = ref('visual');
const jsonCode = ref('');
const originalJsonCode = ref(''); // Для отслеживания изменений в режиме кода
const jsonError = ref(null);
const confirm = useConfirm();
const selectButtonKey = ref(0);
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(() => {
if (isCustom.value) {
activeMode.value = 'code';
jsonCode.value = JSON.stringify(formFields.value, null, 2);
originalJsonCode.value = jsonCode.value;
} else {
activeMode.value = 'visual';
}
});
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 перед переключением режима' });
// Не обновляем activeMode, остаемся в code
cancelModeSwitch();
return;
}
try {
const parsed = JSON.parse(jsonCode.value);
if (hasDuplicateNames(parsed)) {
toastBus.emit('show', { severity: 'error', summary: 'Ошибка валидации', detail: 'В схеме есть поля с одинаковыми именами (name). Исправьте их перед переключением.' });
cancelModeSwitch();
return;
}
// Проверяем, были ли изменения в коде
let hasChanges = false;
try {
// Сравниваем распарсенные объекты, чтобы избежать ложных срабатываний из-за форматирования
const originalObj = JSON.parse(originalJsonCode.value);
hasChanges = JSON.stringify(parsed) !== JSON.stringify(originalObj);
} catch (e) {
hasChanges = true; // Если не смогли распарсить оригинал, считаем что изменения были (или ошибка)
}
// Если переключаемся в визуальный режим
if (newMode === 'visual') {
if (hasChanges) {
// Если были изменения, показываем предупреждение
confirm.require({
group: 'modeSwitch',
header: 'Предупреждение',
message: 'Вы внесли изменения в код вручную.\n\nПосле ручного изменения визуальный редактор может работать некорректно или не отображать все настройки.\n\nВы можете остаться в режиме кода или очистить форму и начать заново в визуальном редакторе.',
icon: 'fa fa-exclamation-triangle',
acceptLabel: 'Очистить и перейти',
rejectLabel: 'Остаться в коде',
acceptClass: 'p-button-danger',
accept: () => {
formFields.value = [];
selectedFieldId.value = null;
isCustom.value = false;
jsonCode.value = '[]';
// Успешный переход
activeMode.value = 'visual';
},
reject: () => {
// Остаемся в коде
// activeMode.value НЕ обновляем, он остался 'code'
cancelModeSwitch();
}
});
// Не обновляем activeMode сразу, ждем решения пользователя
return;
} else {
// Если изменений не было, просто переключаемся и сбрасываем флаг кастомного режима
isCustom.value = false;
formFields.value = parsed;
}
} else {
// Переход в Preview - разрешаем обновление модели из кода
console.log('Updating formFields for Preview:', parsed);
formFields.value = parsed;
}
} catch (e) {
console.error('Ошибка парсинга при переключении:', e);
toastBus.emit('show', { severity: 'error', summary: 'Ошибка', detail: 'Некорректный JSON' });
cancelModeSwitch();
return;
}
}
// Если переключаемся В режим кода
if (newMode === 'code') {
isCustom.value = true;
jsonCode.value = JSON.stringify(formFields.value, null, 2);
originalJsonCode.value = jsonCode.value; // Сохраняем исходное состояние
}
// Обновляем режим для успешных переходов
activeMode.value = newMode;
}
function handleJsonInput() {
try {
const parsed = JSON.parse(jsonCode.value);
if (hasDuplicateNames(parsed)) {
jsonError.value = 'Ошибка: найдены поля с одинаковыми именами (name)';
} else {
jsonError.value = null;
}
// Обновляем модель сразу, чтобы изменения не терялись
console.log('handleJsonInput: Updating formFields', parsed);
formFields.value = parsed;
} catch (e) {
jsonError.value = e.message;
}
}
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: () => {
formFields.value = [];
selectedFieldId.value = null;
jsonCode.value = '[]';
toastBus.emit('show', { severity: 'success', summary: 'Успешно', detail: 'Форма очищена' });
}
});
}
function handleFormSubmit(data) {
console.log('Данные формы:', data);
toastBus.emit('show', { severity: 'success', summary: 'Форма отправлена', detail: 'Данные: ' + JSON.stringify(data, null, 2) });
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,139 @@
<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
:key="`${field.id}-${field.prefixIcon}-${field.suffixIcon}`"
:type="field.$formkit"
v-bind="getFieldProps(field)"
:name="field.name || `field_${field.id}`"
/>
</div>
</div>
</template>
</draggable>
</div>
</template>
<script setup>
import { FormKit } from '@formkit/vue';
import { Button } from 'primevue';
import draggable from 'vuedraggable';
import { useFormFields } from './composables/useFormFields.js';
import { getFieldProps } from './utils/fieldHelpers.js';
import { toastBus } from '@/utils/toastHelper';
// Используем composable для работы с полями
const {
formFields,
selectedFieldId,
fieldErrors,
selectField,
removeField,
isFieldNameUnique
} = useFormFields();
function handleDragChange(evt) {
if (evt.added) {
const addedField = evt.added.element;
// Проверяем уникальность имени
if (!isFieldNameUnique(addedField.name, addedField.id)) {
// Удаляем дубликат
removeField(addedField.id);
toastBus.emit('show', {
severity: 'error',
summary: 'Ошибка добавления',
detail: `Поле с именем "${addedField.name}" уже добавлено в форму.`
});
return;
}
selectField(addedField.id);
}
}
function handleBackgroundClick() {
// Сбрасываем выбор, если есть выбранный элемент
if (selectedFieldId.value) {
selectedFieldId.value = null;
}
}
</script>
<style scoped>
.blueprint-bg {
background-color: #e2e8f0;
background-image: radial-gradient(#cbd5e1 1px, transparent 1px);
background-size: 10px 10px;
}
.ghost-field {
opacity: 0.5;
background-color: #eff6ff;
border-color: #93c5fd;
border-style: dashed;
}
.drag-field {
opacity: 1;
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
transform: scale(1.05);
}
</style>

View File

@@ -0,0 +1,52 @@
<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"
>
<FormKitSchema :schema="schema" />
</FormKit>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { FormKit, FormKitSchema } from '@formkit/vue';
const props = defineProps({
schema: {
type: Array,
required: true,
default: () => [],
},
submitLabel: {
type: String,
default: 'Отправить',
},
});
const emit = defineEmits(['submit']);
function handleSubmit(data) {
emit('submit', data);
}
</script>
<style scoped>
::v-deep(ul.formkit-messages) {
margin-bottom: 0;
padding-left: 0;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,24 @@
import {ru} from '@formkit/i18n';
import * as allIcons from '@formkit/icons';
import {rootClasses} from './formkit.theme.mjs';
// Собираем все иконки в плоский объект
const icons = {};
Object.values(allIcons).forEach(group => {
if (typeof group === 'object') {
Object.assign(icons, group);
}
});
const config = {
locales: {ru},
locale: 'ru',
icons: {
...icons,
},
config: {
rootClasses,
},
};
export default config;

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,9 @@ import ToastService from 'primevue/toastservice';
import {definePreset} from "@primeuix/themes";
import Tooltip from 'primevue/tooltip';
import ConfirmationService from 'primevue/confirmationservice';
import { plugin, defaultConfig } from '@formkit/vue';
import { ru } from '@formkit/i18n';
import config from './formkit.config.js'
const MyPreset = definePreset(Aura, {
@@ -38,6 +41,7 @@ onReady(async () => {
app.use(ToastService);
app.directive('tooltip', Tooltip);
app.use(ConfirmationService);
app.use(plugin, defaultConfig(config));
app.mount('#app');
await useSettingsStore().fetchSettings();

View File

@@ -7,6 +7,7 @@ import MetricsView from "@/views/MetricsView.vue";
import StoreView from "@/views/StoreView.vue";
import MainPageView from "@/views/MainPageView.vue";
import LogsView from "@/views/LogsView.vue";
import FormBuilderView from "@/views/FormBuilderView.vue";
const router = createRouter({
history: createMemoryHistory(),
@@ -19,6 +20,7 @@ const router = createRouter({
{path: '/store', name: 'store', component: StoreView},
{path: '/mainpage', name: 'mainpage', component: MainPageView},
{path: '/logs', name: 'logs', component: LogsView},
{path: '/formbuilder', name: 'formbuilder', component: FormBuilderView},
],
});

View File

@@ -0,0 +1,14 @@
import {defineStore} from "pinia";
import {apiPost} from "@/utils/http.js";
export const useFormsStore = defineStore('forms', {
state: () => ({}),
actions: {
async getFormByAlias(alias) {
return await apiPost('getFormByAlias', {
alias,
});
}
},
});

View File

@@ -63,6 +63,15 @@ export const useSettingsStore = defineStore('settings', {
},
mainpage_blocks: [],
forms: {
checkout: {
alias: '',
friendly_name: '',
is_custom: false,
schema: [],
}
},
},
}),

View File

@@ -0,0 +1,23 @@
<template>
<div v-if="isLoading" class="tw:flex tw:justify-center tw:items-center tw:h-full">
<i class="fa fa-spinner fa-spin tw:text-4xl tw:text-blue-500"></i>
</div>
<div v-else class="tw:h-full">
<FormBuilder
v-model="settings.items.forms.checkout.schema"
v-model:isCustom="settings.items.forms.checkout.is_custom"
/>
</div>
</template>
<script setup>
import {ref} from 'vue';
import FormBuilder from '@/components/FormBuilder/FormBuilder.vue';
import {useSettingsStore} from "@/stores/settings.js";
const formSchema = ref([]);
const isCustom = ref(false);
const isLoading = ref(false);
const settings = useSettingsStore();
</script>

View File

@@ -0,0 +1,9 @@
module.exports = {
prefix: 'tw:',
content: [
'./index.html',
'./src/**/*.{vue,js,ts,jsx,tsx}',
'./templates/**/*.twig',
'./views/**/*.php',
],
};