fix: switch between code and visual for custom forms

This commit is contained in:
2025-12-02 20:03:33 +03:00
parent dd12cb8c34
commit 0ab09aad10
5 changed files with 415 additions and 107 deletions

View File

@@ -33,6 +33,41 @@
<!-- Режим визуального конструктора -->
<template v-if="activeMode === 'visual'">
<!-- Если форма кастомная, показываем предупреждение вместо редактора -->
<div v-if="isCustom" class="tw:flex-1 tw:flex tw:items-center tw:justify-center">
<div class="tw:max-w-2xl tw:p-8 tw:bg-yellow-50 tw:border-2 tw:border-yellow-200 tw:rounded-lg">
<div class="tw:flex tw:items-start tw:gap-4">
<i class="fa fa-exclamation-triangle tw:text-3xl tw:text-yellow-600"></i>
<div>
<h3 class="tw:text-lg tw:font-bold tw:text-yellow-800 tw:mb-2">
Форма является кастомной
</h3>
<p class="tw:text-yellow-700 tw:mb-4">
Эта форма была создана или изменена вручную в редакторе кода и не может быть отображена в визуальном редакторе.
</p>
<p class="tw:text-yellow-700 tw:mb-4">
Для работы с этой формой используйте режим "Код". Если вы хотите создать новую форму в визуальном редакторе, необходимо сбросить текущую форму.
</p>
<Button
label="Перейти в режим кода"
icon="fa fa-code"
@click="activeMode = 'code'"
class="tw:mr-2"
/>
<Button
label="Сбросить и создать новую"
icon="fa fa-trash"
severity="danger"
outlined
@click="showResetConfirmation"
/>
</div>
</div>
</div>
</div>
<!-- Обычный визуальный редактор для некстомных форм -->
<template v-else>
<!-- Панель доступных полей -->
<div class="tw:w-64 tw:flex-shrink-0 tw:h-full tw:overflow-hidden">
<FieldsPanel class="tw:h-full" />
@@ -72,6 +107,7 @@
</div>
</div>
</template>
</template>
<!-- Режим редактирования кода -->
<template v-if="activeMode === 'code'">
@@ -87,7 +123,12 @@
<template v-if="activeMode === 'preview'">
<div class="tw:flex-1 tw:flex tw:justify-center tw:overflow-auto">
<div class="tw:w-full">
<div v-if="jsonError" class="tw:p-4 tw:bg-red-50 tw:border tw:border-red-200 tw:rounded tw:text-red-700">
<i class="fa fa-exclamation-circle tw:mr-2"></i>
Ошибка в схеме: {{ jsonError }}
</div>
<FormRenderer
v-else
:schema="formSchema"
submit-label="Отправить форму"
@submit="handleFormSubmit"
@@ -101,7 +142,7 @@
</template>
<script setup>
import { ref, provide, computed, onMounted } from 'vue';
import { ref, provide, computed, onMounted, watch } from 'vue';
import { Button, Panel, SelectButton, useConfirm, ConfirmPopup, ConfirmDialog } from 'primevue';
import FieldsPanel from '@/components/FormBuilder/FieldsPanel.vue';
import FormCanvas from '@/components/FormBuilder/FormCanvas.vue';
@@ -109,6 +150,8 @@ import FieldSettings from '@/components/FormBuilder/FieldSettings.vue';
import FormRenderer from '@/components/FormBuilder/FormRenderer.vue';
import CodeEditor from '@/components/FormBuilder/CodeEditor.vue';
import { toastBus } from '@/utils/toastHelper';
import { saveRevision } from './utils/revisionManager.js';
import { createEmptySchema } from './utils/schemaParser.js';
const formFields = defineModel({
type: Array,
@@ -120,13 +163,19 @@ const isCustom = defineModel('isCustom', {
default: false
});
// Локальные состояния (не сохраняются в БД)
const dirtyFromCode = ref(false);
const lastSyncedSchema = ref(null);
const activeMode = ref('visual');
const jsonCode = ref('');
const originalJsonCode = ref(''); // Для отслеживания изменений в режиме кода
const jsonError = ref(null);
const confirm = useConfirm();
const selectButtonKey = ref(0);
// Алиас формы для сохранения ревизий (можно передавать через props, пока используем 'checkout')
const formAlias = 'checkout';
const modes = [
{ label: 'Визуальный', value: 'visual', icon: 'fa fa-th-large' },
{ label: 'Код', value: 'code', icon: 'fa fa-code' },
@@ -149,14 +198,49 @@ const formSchema = computed(() => {
return formFields.value || [];
});
// Инициализация режима при монтировании
// Инициализация при монтировании
onMounted(() => {
initializeForm();
});
// Инициализация формы при загрузке из БД
function initializeForm() {
const schema = formFields.value || [];
// Устанавливаем lastSyncedSchema равным загруженной схеме
lastSyncedSchema.value = JSON.parse(JSON.stringify(schema));
// dirtyFromCode всегда false при загрузке
dirtyFromCode.value = false;
// Определяем начальный режим на основе isCustom
if (isCustom.value) {
activeMode.value = 'code';
jsonCode.value = JSON.stringify(formFields.value, null, 2);
originalJsonCode.value = jsonCode.value;
jsonCode.value = JSON.stringify(schema, null, 2);
} else {
activeMode.value = 'visual';
jsonCode.value = JSON.stringify(schema, null, 2);
}
}
// Отслеживание изменений в визуальном редакторе
watch(formFields, (newSchema) => {
// Если мы в визуальном режиме и форма не кастомная, обновляем lastSyncedSchema
if (activeMode.value === 'visual' && !dirtyFromCode.value && !isCustom.value) {
lastSyncedSchema.value = JSON.parse(JSON.stringify(newSchema));
// Обновляем jsonCode для синхронизации
jsonCode.value = JSON.stringify(newSchema, null, 2);
}
}, { deep: true });
// Отслеживание изменений isCustom для переинициализации
watch(isCustom, () => {
// При изменении isCustom извне (например, при загрузке из БД) переинициализируем
if (activeMode.value === 'code' && !isCustom.value) {
// Если isCustom стал false, но мы в режиме кода, это значит форма была сброшена
// Синхронизируем состояния
lastSyncedSchema.value = JSON.parse(JSON.stringify(formFields.value));
dirtyFromCode.value = false;
}
});
@@ -188,7 +272,6 @@ function handleModeChange(newMode) {
// Пытаемся распарсить JSON перед уходом
if (jsonError.value) {
toastBus.emit('show', { severity: 'error', summary: 'Ошибка JSON', detail: 'Исправьте ошибки в JSON перед переключением режима' });
// Не обновляем activeMode, остаемся в code
cancelModeSwitch();
return;
}
@@ -202,57 +285,47 @@ function handleModeChange(newMode) {
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) {
// Если были изменения, показываем предупреждение
// Проверяем, нужно ли показывать предупреждение
const needsWarning = isCustom.value || dirtyFromCode.value;
if (needsWarning) {
// Сохраняем ревизию перед деструктивной операцией
saveRevision(formAlias, formFields.value, 'reset_to_visual');
// Показываем предупреждение
confirm.require({
group: 'modeSwitch',
header: 'Предупреждение',
message: 'Вы внесли изменения в код вручную.\n\nПосле ручного изменения визуальный редактор может работать некорректно или не отображать все настройки.\n\nВы можете остаться в режиме кода или очистить форму и начать заново в визуальном редакторе.',
message: isCustom.value
? 'Загруженная форма является кастомной и может содержать неподдерживаемые элементы.\n\nОткрытие визуального редактора приведет к полному сбросу формы. Все нестандартные настройки будут потеряны.'
: 'Форма была изменена вручную в редакторе кода.\n\nВизуальный редактор не поддерживает все конструкции, и для его открытия потребуется полностью сбросить форму и создать новую. Все нестандартные настройки будут потеряны.',
icon: 'fa fa-exclamation-triangle',
acceptLabel: 'Очистить и перейти',
rejectLabel: 'Остаться в коде',
acceptLabel: 'Сбросить и открыть визуальный редактор',
rejectLabel: 'Отменить',
acceptClass: 'p-button-danger',
accept: () => {
formFields.value = [];
selectedFieldId.value = null;
isCustom.value = false;
jsonCode.value = '[]';
// Успешный переход
resetFormToVisual();
activeMode.value = 'visual';
},
reject: () => {
// Остаемся в коде
// activeMode.value НЕ обновляем, он остался 'code'
cancelModeSwitch();
}
});
// Не обновляем activeMode сразу, ждем решения пользователя
return;
} else {
// Если изменений не было, просто переключаемся и сбрасываем флаг кастомного режима
isCustom.value = false;
// Если isCustom=false и dirtyFromCode=false, просто переключаемся
// Обновляем схему из кода
formFields.value = parsed;
lastSyncedSchema.value = JSON.parse(JSON.stringify(parsed));
dirtyFromCode.value = false;
}
} else if (newMode === 'preview') {
// Переход в Preview - обновляем модель из кода
formFields.value = parsed;
}
} 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' });
@@ -263,15 +336,70 @@ function handleModeChange(newMode) {
// Если переключаемся В режим кода
if (newMode === 'code') {
isCustom.value = true;
// Обновляем jsonCode из текущей схемы
jsonCode.value = JSON.stringify(formFields.value, null, 2);
originalJsonCode.value = jsonCode.value; // Сохраняем исходное состояние
// Обновляем lastSyncedSchema, если мы пришли из визуального редактора
if (activeMode.value === 'visual') {
lastSyncedSchema.value = JSON.parse(JSON.stringify(formFields.value));
dirtyFromCode.value = false;
}
// isCustom не меняем автоматически при переходе в режим кода
// Он установится в true только когда пользователь реально изменит код (через handleJsonInput)
}
// Если переключаемся В визуальный режим из preview
if (newMode === 'visual' && activeMode.value === 'preview') {
// Никаких проверок, просто переключаемся
}
// Обновляем режим для успешных переходов
activeMode.value = newMode;
}
/**
* Сбрасывает форму для визуального редактора
*/
function resetFormToVisual() {
// Сохраняем ревизию (уже сохранена выше, но на всякий случай)
saveRevision(formAlias, formFields.value, 'reset_to_visual');
// Создаем пустую схему
const emptySchema = createEmptySchema();
// Обновляем все состояния
formFields.value = emptySchema;
selectedFieldId.value = null;
isCustom.value = false;
jsonCode.value = JSON.stringify(emptySchema, null, 2);
lastSyncedSchema.value = JSON.parse(JSON.stringify(emptySchema));
dirtyFromCode.value = false;
}
/**
* Показывает подтверждение сброса формы
*/
function showResetConfirmation() {
// Сохраняем ревизию перед деструктивной операцией
saveRevision(formAlias, formFields.value, 'reset_to_visual');
confirm.require({
group: 'modeSwitch',
header: 'Подтверждение сброса',
message: 'Вы уверены, что хотите сбросить кастомную форму и создать новую в визуальном редакторе?\n\nВсе текущие настройки будут потеряны.',
icon: 'fa fa-exclamation-triangle',
acceptLabel: 'Сбросить и создать новую',
rejectLabel: 'Отменить',
acceptClass: 'p-button-danger',
accept: () => {
resetFormToVisual();
activeMode.value = 'visual';
},
reject: () => {
// Отменяем действие
}
});
}
function handleJsonInput() {
try {
@@ -284,10 +412,29 @@ function handleJsonInput() {
}
// Обновляем модель сразу, чтобы изменения не терялись
console.log('handleJsonInput: Updating formFields', parsed);
formFields.value = parsed;
// Проверяем, изменилась ли схема относительно lastSyncedSchema
if (lastSyncedSchema.value !== null) {
const currentSchemaStr = JSON.stringify(parsed);
const lastSyncedStr = JSON.stringify(lastSyncedSchema.value);
const hasChanges = currentSchemaStr !== lastSyncedStr;
dirtyFromCode.value = hasChanges;
// Если есть изменения и форма еще не кастомная, устанавливаем isCustom=true
if (hasChanges && !isCustom.value) {
isCustom.value = true;
}
} else {
// Если lastSyncedSchema еще не установлен, считаем что изменения есть
dirtyFromCode.value = true;
if (!isCustom.value) {
isCustom.value = true;
}
}
} catch (e) {
jsonError.value = e.message;
// При ошибке парсинга не обновляем dirtyFromCode и isCustom
}
}
@@ -302,9 +449,18 @@ function clearForm(event) {
acceptClass: 'p-button-danger p-button-sm',
rejectClass: 'p-button-secondary p-button-sm p-button-text',
accept: () => {
formFields.value = [];
// Сохраняем ревизию перед очисткой
saveRevision(formAlias, formFields.value, 'clear_form');
const emptySchema = createEmptySchema();
formFields.value = emptySchema;
selectedFieldId.value = null;
jsonCode.value = '[]';
jsonCode.value = JSON.stringify(emptySchema, null, 2);
lastSyncedSchema.value = JSON.parse(JSON.stringify(emptySchema));
dirtyFromCode.value = false;
// После очистки в визуальном редакторе форма не кастомная
isCustom.value = false;
toastBus.emit('show', { severity: 'success', summary: 'Успешно', detail: 'Форма очищена' });
}
});

View File

@@ -58,11 +58,16 @@
<!-- Предпросмотр поля -->
<div class="tw:relative tw:z-0">
<FormKit
v-if="field.$formkit"
:key="`${field.id}-${field.prefixIcon}-${field.suffixIcon}`"
:type="field.$formkit"
v-bind="getFieldProps(field)"
:name="field.name || `field_${field.id}`"
/>
<div v-else class="tw:text-red-500 tw:text-sm">
<i class="fa fa-exclamation-triangle tw:mr-2"></i>
Поле без типа $formkit
</div>
</div>
</div>
</template>

View File

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

View File

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

View File

@@ -79,7 +79,12 @@ class SettingsHandler
if ($forms) {
foreach ($forms as $form) {
$schema = json_decode($form['schema'], true, 512, JSON_THROW_ON_ERROR);
try {
$schema = json_decode($form['schema'] ?? '[]', true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $exception) {
$schema = [];
}
$data['forms'][$form['alias']] = [
'alias' => $form['alias'],
'friendly_name' => $form['friendly_name'],