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:
1175
frontend/admin/package-lock.json
generated
1175
frontend/admin/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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>
|
||||
320
frontend/admin/src/components/FormBuilder/FormBuilder.vue
Normal file
320
frontend/admin/src/components/FormBuilder/FormBuilder.vue
Normal 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>
|
||||
139
frontend/admin/src/components/FormBuilder/FormCanvas.vue
Normal file
139
frontend/admin/src/components/FormBuilder/FormCanvas.vue
Normal 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>
|
||||
52
frontend/admin/src/components/FormBuilder/FormRenderer.vue
Normal file
52
frontend/admin/src/components/FormBuilder/FormRenderer.vue
Normal 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>
|
||||
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, 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',
|
||||
};
|
||||
24
frontend/admin/src/formkit.config.js
Normal file
24
frontend/admin/src/formkit.config.js
Normal 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;
|
||||
3398
frontend/admin/src/formkit.theme.mjs
Normal file
3398
frontend/admin/src/formkit.theme.mjs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
|
||||
@@ -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},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
14
frontend/admin/src/stores/forms.js
Normal file
14
frontend/admin/src/stores/forms.js
Normal 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,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -63,6 +63,15 @@ export const useSettingsStore = defineStore('settings', {
|
||||
},
|
||||
|
||||
mainpage_blocks: [],
|
||||
|
||||
forms: {
|
||||
checkout: {
|
||||
alias: '',
|
||||
friendly_name: '',
|
||||
is_custom: false,
|
||||
schema: [],
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
|
||||
23
frontend/admin/src/views/FormBuilderView.vue
Normal file
23
frontend/admin/src/views/FormBuilderView.vue
Normal 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>
|
||||
9
frontend/admin/tailwind.config.js
Normal file
9
frontend/admin/tailwind.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
prefix: 'tw:',
|
||||
content: [
|
||||
'./index.html',
|
||||
'./src/**/*.{vue,js,ts,jsx,tsx}',
|
||||
'./templates/**/*.twig',
|
||||
'./views/**/*.php',
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user