{ "index": 331, "slug": "editorial-2018-10-field-image-workflow", "title": "Bitrix: как не потерять изображение между preview и сохранением формы", "excerpt": "Preview показывает выбранный файл, но не доказывает, что Bitrix получил его и записал в PREVIEW_PICTURE. Разделяем нативный multipart-контракт и AJAX-режим FileInput, разбираем ветки замены и удаления и проверяем результат после обновления элемента.", "contentHtml": "

Редактор показывает новую фотографию, пользователь нажимает «Сохранить», а после перезагрузки карточка снова открывается со старой. Иногда поле изображения становится пустым. В файловом хранилище при этом остаются лишние загрузки без связи с элементом. Оператор повторяет действие, а разработчик видит только удачный preview и ответ HTTP 200. Ошибка стоит времени пользователя, мусора в хранилище и риска опубликовать карточку не с тем изображением.

\n

Preview подтверждает только состояние интерфейса. Надёжное сохранение проходит три границы: браузер отправляет файл, сервер принимает и проверяет его, Bitrix обновляет поле элемента. Важно выбрать один контракт загрузки: нативная форма передаёт файл в $_FILES, а AJAX-контрол Bitrix сначала создаёт своё значение. Если смешать эти пути, экран может быть правильным, а PREVIEW_PICTURE — прежним.

\n

У изображения несколько состояний

\n

До отправки браузер владеет выбранным объектом File. Preview владеет картинкой на экране и текстом статуса. После обычной отправки файл появляется в $_FILES; после отдельной AJAX-загрузки контрол возвращает путь или хеш в своём формате. Только серверный обработчик решает, какое значение можно передать в CIBlockElement::Update(). Поле PREVIEW_PICTURE принадлежит элементу инфоблока, поэтому его нужно перечитать после обновления.

\n

В legacy-шаблоне рядом могут жить поле выбора, скрытый идентификатор, HTML-редактор и Ajax-перерисовка контейнера. У каждого значения должна быть одна роль. Скрытый PHOTO_ID не превращает локальный файл в сохранённый. Идентификатор отдельной загрузки нужно связать с пользователем и элементом; брать «последний файл» из базы нельзя, потому что параллельный запрос может изменить результат.

\n
СигналВладелецЧто он доказывает
CATALOG_PREVIEW в $_FILESбраузер и multipart POSTфайл дошёл до PHP
DELETE_PICTURE=1явное действие пользователязапрошено удаление
.js-photo-state и previewDOM и jQueryтолько обратная связь
PREVIEW_PICTUREэлемент инфоблокасохранённая привязка файла
\n
\"Схема
Preview — сигнал браузера. Результат появляется только после проверки multipart-запроса, успешного обновления элемента и нового чтения карточки.
\n

Нативная форма: самый короткий контракт

\n

Для простого legacy-сценария достаточно обычного поля файла. У формы есть enctype=\"multipart/form-data\", у поля — имя, которое сервер ищет в $_FILES. Этот пример намеренно не использует FileInput: так легче увидеть, какой запрос должен прийти обработчику.

\n
<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 означает ответ сервера, но не успешное обновление элемента.

\n
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

Сервер выбирает одну ветку

\n

Для редактирования изображения нужны три операции: оставить старое, заменить новым или удалить. Запрет на одновременные замену и удаление — это политика данного обработчика, а не обещание самого Bitrix. Она убирает зависимость от порядка полей в запросе. Пустой input type=\"file\" означает «нового файла нет», но не означает «удалить старый».

\n
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_* нельзя превращать в «файла нет»: ошибка лимита или сбой временного хранилища должны стать ошибкой формы.

\n

Документация Bitrix описывает для CIBlockElement::Update() массив полей и сообщает, что при false текст причины находится в LAST_ERROR. Для уже существующего серверного файла нужен подтверждённый путь и файловый массив, например через CFile::MakeFileArray(). Для файлового свойства, а не поля PREVIEW_PICTURE, формат PROPERTY_VALUES проверяйте отдельно.

\n

FileInput: другой способ передачи

\n

\\Bitrix\\Main\\UI\\FileInput удобен, когда нужен готовый контрол выбора и загрузки. Но параметр upload => true включает отдельную AJAX-загрузку. В документации у него есть uploadType со значениями path и hash; результат такого контрола не следует автоматически искать в $_FILES['CATALOG_PREVIEW'].

\n
<?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 по первому контракту.

\n

Симптом → причина → проверка → действие

\n
СимптомВероятная причинаПроверкаДействие
Preview новый, карточка стараяфайл не попал в POST или не вызван Update()Network: multipart-часть, ответ обработчика и свежая выборкаисправить имя поля или ветку сохранения
Ответ 200, поле пустоеошибка скрыта в теле ответа или неверен формат поляпроверить JSON, LAST_ERROR и ID элементавернуть ошибку формы и перечитать элемент
Ajax работает со второго разаserialize() не передаёт файл или обработчик дублируетсяпроверить Request Payload и число срабатываний changeиспользовать FormData и namespace
Удаление срабатывает самопустой preview ошибочно принят за команду удалениясравнить явный флаг с данными формыпередавать отдельный флаг удаления
Появляются сиротские файлыотдельная загрузка завершилась без обновления элементасопоставить временный ID с пользователем и элементомввести владельца и очистку незавершённых загрузок
\n

Проверка по порядку отказа

\n
  1. Открываю карточку с известным текущим ID изображения и фиксирую его до изменения.
  2. Отправляю форму без нового файла и без удаления. После свежего чтения ID должен остаться прежним.
  3. Выбираю небольшой учебный JPEG и проверяю в Network имя поля, multipart-часть и размер файла.
  4. Проверяю ответ обработчика, результат Update() и LAST_ERROR, если метод вернул false.
  5. Открываю карточку новым HTTP-запросом и сравниваю URL изображения с ожидаемым файлом.
  6. Отправляю явное удаление без нового файла и проверяю, что сработала только ветка удаления.
  7. Отправляю новый файл вместе с удалением. Ожидаю понятную ошибку формы.
  8. Перерисовываю контейнер Ajax-ом и меняю файл ещё раз. Событие должно обработаться один раз.
  9. Повторяю сценарий с превышенным размером и повреждённым изображением. Обработчик должен вернуть ошибку до изменения элемента.
\n

Ограничения

\n

Пример не задаёт универсальные размеры, MIME-типы, права и антивирусную проверку. Эти правила зависят от версии Bitrix, настроек PHP, роли пользователя и требований каталога. Проверка расширения в браузере не защищает сервер. Поле PREVIEW_PICTURE и файловое свойство используют разные контракты, поэтому их нельзя менять одним предположением.

\n

Если FileInput загружает файл отдельным запросом, путь через $_FILES неприменим без адаптации. Сначала определите ответ контролла и место хранения временного файла. При отмене формы не оставляйте такой идентификатор без владельца. Для небольшой синхронной формы не нужны очереди, но нужна понятная очистка незавершённых загрузок.

\n

Критерий готовности

\n

Сценарий готов, если форма делает один понятный запрос, сервер принимает только допустимый файл, Update() возвращает успех, а свежая страница показывает новое изображение. Отправка без нового файла оставляет старое значение. Явное удаление меняет поле только по флагу пользователя. Конфликт «новый файл плюс удаление» даёт ошибку. После Ajax-перерисовки нет двойного обработчика. Эти условия проверяются на тестовой записи и не являются заявлением о результате рабочей среды.

\n

Проверяемые источники

\n" }