{ "index": 331, "slug": "editorial-2018-10-field-image-workflow", "title": "Bitrix: как не потерять изображение между preview и сохранением формы", "excerpt": "Preview показывает выбранный файл, но не доказывает, что Bitrix получил его и записал в PREVIEW_PICTURE. Разделяем нативный multipart-контракт и AJAX-режим FileInput, разбираем ветки замены и удаления и проверяем результат после обновления элемента.", "contentHtml": "
Редактор показывает новую фотографию, пользователь нажимает «Сохранить», а после перезагрузки карточка снова открывается со старой. Иногда поле изображения становится пустым. В файловом хранилище при этом остаются лишние загрузки без связи с элементом. Оператор повторяет действие, а разработчик видит только удачный preview и ответ HTTP 200. Ошибка стоит времени пользователя, мусора в хранилище и риска опубликовать карточку не с тем изображением.
\nPreview подтверждает только состояние интерфейса. Надёжное сохранение проходит три границы: браузер отправляет файл, сервер принимает и проверяет его, Bitrix обновляет поле элемента. Важно выбрать один контракт загрузки: нативная форма передаёт файл в $_FILES, а AJAX-контрол Bitrix сначала создаёт своё значение. Если смешать эти пути, экран может быть правильным, а PREVIEW_PICTURE — прежним.
До отправки браузер владеет выбранным объектом File. Preview владеет картинкой на экране и текстом статуса. После обычной отправки файл появляется в $_FILES; после отдельной AJAX-загрузки контрол возвращает путь или хеш в своём формате. Только серверный обработчик решает, какое значение можно передать в CIBlockElement::Update(). Поле PREVIEW_PICTURE принадлежит элементу инфоблока, поэтому его нужно перечитать после обновления.
В legacy-шаблоне рядом могут жить поле выбора, скрытый идентификатор, HTML-редактор и Ajax-перерисовка контейнера. У каждого значения должна быть одна роль. Скрытый PHOTO_ID не превращает локальный файл в сохранённый. Идентификатор отдельной загрузки нужно связать с пользователем и элементом; брать «последний файл» из базы нельзя, потому что параллельный запрос может изменить результат.
| Сигнал | Владелец | Что он доказывает |
|---|---|---|
CATALOG_PREVIEW в $_FILES | браузер и multipart POST | файл дошёл до PHP |
DELETE_PICTURE=1 | явное действие пользователя | запрошено удаление |
.js-photo-state и preview | DOM и jQuery | только обратная связь |
PREVIEW_PICTURE | элемент инфоблока | сохранённая привязка файла |
Для простого legacy-сценария достаточно обычного поля файла. У формы есть enctype=\"multipart/form-data\", у поля — имя, которое сервер ищет в $_FILES. Этот пример намеренно не использует FileInput: так легче увидеть, какой запрос должен прийти обработчику.
<form method=\"post\" enctype=\"multipart/form-data\" id=\"catalog-photo-form\">\n <input type=\"file\" name=\"CATALOG_PREVIEW\" accept=\"image/*\">\n <label>\n <input type=\"checkbox\" name=\"DELETE_PICTURE\" value=\"1\">\n удалить текущее изображение\n </label>\n <p class=\"js-photo-state\" aria-live=\"polite\"></p>\n <button type=\"submit\">Сохранить</button>\n</form>\nЕсли форма отправляется Ajax-ом, соберите её через FormData. Метод serialize() не передаёт содержимое поля файла. Заголовок Content-Type вручную не задавайте: браузер добавляет boundary, по которому сервер разделяет части запроса. В Network проверяйте имя поля, размер части, код ответа и тело ответа. HTTP 200 означает ответ сервера, но не успешное обновление элемента.
var form = document.getElementById('catalog-photo-form');\n\nform.addEventListener('submit', function (event) {\n event.preventDefault();\n\n var data = new FormData(form);\n fetch('/admin/catalog/photo.php', {\n method: 'POST',\n body: data\n })\n .then(function (response) {\n if (!response.ok) {\n throw new Error('HTTP ' + response.status);\n }\n return response.json();\n })\n .then(function (result) {\n if (!result.ok) {\n throw new Error(result.error || 'Изображение не сохранено');\n }\n });\n});\nДля статуса выбора можно использовать делегированный обработчик jQuery. Он остаётся рабочим после замены дочернего HTML-контейнера. Namespace позволяет снять только обработчик этого сценария:
\n(function ($) {\n $(document)\n .off('change.photoWorkflow', 'input[name=CATALOG_PREVIEW]')\n .on('change.photoWorkflow', 'input[name=CATALOG_PREVIEW]', function () {\n var file = this.files && this.files[0];\n $('.js-photo-state').text(file\n ? 'Выбран файл: ' + file.name + '. Сохранение ещё не выполнено.'\n : 'Новый файл не выбран.');\n });\n}(jQuery));\nТекст намеренно говорит о выборе, а не о сохранении. Имя файла, data URL и размер полезны для экрана, но не могут заменять серверный ID. После ответа сервера статус нужно менять только по явному полю результата, а не по факту отправки формы.
\nДля редактирования изображения нужны три операции: оставить старое, заменить новым или удалить. Запрет на одновременные замену и удаление — это политика данного обработчика, а не обещание самого Bitrix. Она убирает зависимость от порядка полей в запросе. Пустой input type=\"file\" означает «нового файла нет», но не означает «удалить старый».
function updatePreviewPicture($elementId, array $post, array $files)\n{\n $upload = $files['CATALOG_PREVIEW'] ?? array();\n $error = $upload['error'] ?? UPLOAD_ERR_NO_FILE;\n $delete = ($post['DELETE_PICTURE'] ?? '') === '1';\n\n if ($error !== UPLOAD_ERR_NO_FILE && $error !== UPLOAD_ERR_OK) {\n throw new RuntimeException('Загрузка файла завершилась ошибкой: ' . $error);\n }\n\n $hasNewFile = $error === UPLOAD_ERR_OK;\n if ($hasNewFile && (empty($upload['tmp_name']) || !is_uploaded_file($upload['tmp_name']))) {\n throw new RuntimeException('Файл не найден во временном хранилище');\n }\n\n if ($hasNewFile && $delete) {\n throw new RuntimeException('Выберите замену или удаление');\n }\n if (!$hasNewFile && !$delete) {\n return false; // оставить текущее значение\n }\n\n $fields = array(\n 'PREVIEW_PICTURE' => $hasNewFile\n ? $upload\n : array('del' => 'Y'),\n );\n $element = new CIBlockElement();\n\n if (!$element->Update((int) $elementId, $fields)) {\n throw new RuntimeException($element->LAST_ERROR);\n }\n\n return true;\n}\nКод показывает минимальную развилку, а не готовую политику приёма файлов. Перед Update() сервер должен ограничить размер, проверить фактический тип изображения, права пользователя и принадлежность элемента. Значения UPLOAD_ERR_* нельзя превращать в «файла нет»: ошибка лимита или сбой временного хранилища должны стать ошибкой формы.
Документация Bitrix описывает для CIBlockElement::Update() массив полей и сообщает, что при false текст причины находится в LAST_ERROR. Для уже существующего серверного файла нужен подтверждённый путь и файловый массив, например через CFile::MakeFileArray(). Для файлового свойства, а не поля PREVIEW_PICTURE, формат PROPERTY_VALUES проверяйте отдельно.
\\Bitrix\\Main\\UI\\FileInput удобен, когда нужен готовый контрол выбора и загрузки. Но параметр upload => true включает отдельную AJAX-загрузку. В документации у него есть uploadType со значениями path и hash; результат такого контрола не следует автоматически искать в $_FILES['CATALOG_PREVIEW'].
<?php\nuse Bitrix\\Main\\UI\\FileInput;\n\necho FileInput::createInstance(array(\n 'id' => 'catalog_preview',\n 'name' => 'CATALOG_PREVIEW',\n 'upload' => true,\n 'uploadType' => 'hash',\n 'allowUpload' => FileInput::UPLOAD_IMAGES,\n 'maxCount' => 1,\n 'delete' => true,\n))->show($files);\nДля этого варианта сначала определите реальный ответ контрол-эндпойнта и способ, которым проект вызывает prepareFile(). Затем сервер должен проверить подпись или идентификатор, владельца временного файла, элемент и срок жизни загрузки, после чего передать подготовленный файловый массив в обновление. Если нужна одна обычная отправка формы, не включайте AJAX-загрузку FileInput: используйте нативное поле и обрабатывайте $_FILES по первому контракту.
| Симптом | Вероятная причина | Проверка | Действие |
|---|---|---|---|
| Preview новый, карточка старая | файл не попал в POST или не вызван Update() | Network: multipart-часть, ответ обработчика и свежая выборка | исправить имя поля или ветку сохранения |
| Ответ 200, поле пустое | ошибка скрыта в теле ответа или неверен формат поля | проверить JSON, LAST_ERROR и ID элемента | вернуть ошибку формы и перечитать элемент |
| Ajax работает со второго раза | serialize() не передаёт файл или обработчик дублируется | проверить Request Payload и число срабатываний change | использовать FormData и namespace |
| Удаление срабатывает само | пустой preview ошибочно принят за команду удаления | сравнить явный флаг с данными формы | передавать отдельный флаг удаления |
| Появляются сиротские файлы | отдельная загрузка завершилась без обновления элемента | сопоставить временный ID с пользователем и элементом | ввести владельца и очистку незавершённых загрузок |
Update() и LAST_ERROR, если метод вернул false.Пример не задаёт универсальные размеры, MIME-типы, права и антивирусную проверку. Эти правила зависят от версии Bitrix, настроек PHP, роли пользователя и требований каталога. Проверка расширения в браузере не защищает сервер. Поле PREVIEW_PICTURE и файловое свойство используют разные контракты, поэтому их нельзя менять одним предположением.
Если FileInput загружает файл отдельным запросом, путь через $_FILES неприменим без адаптации. Сначала определите ответ контролла и место хранения временного файла. При отмене формы не оставляйте такой идентификатор без владельца. Для небольшой синхронной формы не нужны очереди, но нужна понятная очистка незавершённых загрузок.
Сценарий готов, если форма делает один понятный запрос, сервер принимает только допустимый файл, Update() возвращает успех, а свежая страница показывает новое изображение. Отправка без нового файла оставляет старое значение. Явное удаление меняет поле только по флагу пользователя. Конфликт «новый файл плюс удаление» даёт ошибку. После Ajax-перерисовки нет двойного обработчика. Эти условия проверяются на тестовой записи и не являются заявлением о результате рабочей среды.