30 KiB
Интеграция с Bitrix — спецификация для backend-разработчика
Это документ для человека, который не участвовал в разработке самого React-приложения (portal-chart-builder) и не обязан знать TypeScript — здесь только то, что нужно на стороне PHP/Bitrix.
1. Что это
Конструктор диаграмм — отдельное React-приложение (не часть Bitrix24, продукт — «1С-Битрикс: Управление сайтом»). Встраивается как пользовательский тип свойства инфоблока: в админке, на странице редактирования элемента, вместо обычного поля отображается iframe с редактором диаграммы. Значение свойства — JSON-строка со схемой диаграммы, хранится как обычное строковое свойство (PROPERTY_TYPE => 'S').
Никакого REST-модуля, вебхуков или JS SDK Bitrix24 не требуется — это нативный механизм модуля iblock.
2. Регистрация типа свойства
В local/php_interface/init.php:
AddEventHandler("iblock", "OnIBlockPropertyBuildList", ["DiagramProperty", "GetUserTypeDescription"]);
После регистрации новый тип появляется в Контент → Инфоблоки → [нужный] → Свойства → Тип свойства. Свойство должно быть одиночным (MULTIPLE = N) — одна диаграмма на элемент.
3. Скелет PHP-класса
Ниже — иллюстративный каркас, не готовый к продакшену код. Реализация валидации, экранирования и т.д. — на усмотрение разработчика, но с обязательным соблюдением требований из раздела 5.
class DiagramProperty
{
public static function GetUserTypeDescription(): array
{
return [
'PROPERTY_TYPE' => 'S',
'USER_TYPE' => 'DiagramEditor',
'DESCRIPTION' => 'Конструктор диаграмм процессов',
'GetPropertyFieldHtml' => [__CLASS__, 'GetPropertyFieldHtml'],
'ConvertToDB' => [__CLASS__, 'ConvertToDB'],
'ConvertFromDB' => [__CLASS__, 'ConvertFromDB'],
];
}
public static function GetPropertyFieldHtml($arProperty, $value, $strHTMLControlName): string
{
$inputName = htmlspecialcharsbx($strHTMLControlName['VALUE']);
$rawValue = $value['VALUE'] ?? '';
// Вариант A — рекомендуемый, приложение лежит внутри дерева портала (см. раздел 7):
// appOrigin — это просто origin текущего запроса, appUrl — конкретный файл.
// ВАЖНО: имя файла — не константа. dist/index.html выходит из сборки как
// index-<hash>.html (хеш меняется при каждой значимой пересборке, см.
// раздел 7) — 'index.html' строкой здесь писать нельзя, нужно получать
// актуальное имя каким-то способом на своей стороне (см. раздел 7).
$appOrigin = (CMain::IsHTTPS() ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'];
$appUrl = $appOrigin . '/local/diagram-app/' . GetActualDiagramAppIndexFile(); // пример, не готовая функция
// Вариант B — если приложение развёрнуто отдельным контейнером (см. раздел 7),
// appUrl/appOrigin вместо этого указывают на его отдельный адрес, например:
// $appOrigin = 'http://localhost:5178';
// $appUrl = $appOrigin;
ob_start();
?>
<input type="hidden" name="<?= $inputName ?>" id="<?= $inputName ?>" value="<?= htmlspecialcharsbx($rawValue) ?>">
<iframe id="diagram-frame-<?= $inputName ?>" src="<?= htmlspecialcharsbx($appUrl) ?>"
style="width:100%;height:600px;border:1px solid #ccc"></iframe>
<script>
(function () {
// Читаем initialValue из самого input.value, а не встраиваем JSON в
// инлайн-скрипт — так не нужны JSON_HEX_* флаги вообще (см. раздел 5):
// htmlspecialcharsbx() выше уже достаточно экранирует значение
// HTML-атрибута, а браузер сам декодирует его обратно в текст.
var appOrigin = <?= json_encode($appOrigin) ?>;
var input = document.getElementById(<?= json_encode($inputName) ?>);
var frame = document.getElementById('diagram-frame-<?= $inputName ?>');
window.addEventListener('message', function (event) {
if (event.origin !== appOrigin) return; // обязательная проверка, см. раздел 5
if (!event.data || typeof event.data !== 'object') return;
if (event.data.type === 'diagram:ready') {
frame.contentWindow.postMessage(
{ type: 'diagram:init', payload: input.value || null },
appOrigin // никогда '*', см. раздел 5
);
} else if (event.data.type === 'diagram:change') {
input.value = event.data.payload;
}
});
})();
</script>
<?php
return ob_get_clean();
}
public static function ConvertToDB($arProperty, $value): array
{
// Провалидировать, что VALUE — либо пустая строка, либо синтаксически валидный JSON,
// прежде чем отдавать штатному механизму сохранения.
return $value;
}
public static function ConvertFromDB($arProperty, $value): array
{
return $value;
}
}
GetPublicViewHTML/GetPublicEditHTML сейчас не реализуются — публичная часть сайта не нужна в первой версии (диаграмма видна только в админке при редактировании элемента).
4. Контракт postMessage (реализован во frontend, src/host/bitrixAdapter.ts)
Обмен — три типа сообщений, строго в этом порядке:
1. Страница элемента загружается → мост-скрипт (см. раздел 3) готов, держит initialValue
2. iframe с приложением загружается
3. Приложение → мост: { type: 'diagram:ready' }
4. Мост → приложение: { type: 'diagram:init', payload: <JSON-строка диаграммы или null> }
5. Пользователь редактирует → с debounce ~400мс:
Приложение → мост: { type: 'diagram:change', payload: '<JSON-строка диаграммы>' }
6. Мост при каждом diagram:change пишет payload в value hidden input
7. Пользователь жмёт штатное «Сохранить» → браузер сабмитит форму → ConvertToDB → сохранение
Важно: payload в diagram:change — уже сериализованная JSON-строка (JSON.stringify со стороны приложения), не объект. Мост должен положить её в input.value как есть, без повторной обработки.
diagram:init с payload: null (или пустой строкой) — валидный случай: новый элемент без диаграммы, приложение стартует с чистого холста.
Режим только для просмотра
Хост может открыть приложение на чтение, добавив ?mode=view к URL iframe:
/local/diagram-app/index.html?mode=view
Тогда редактор стартует в режиме «Просмотр» и переключатель «Просмотр/Редактирование» скрыт — фигуры нельзя двигать, создавать и удалять. Без параметра поведение прежнее: редактирование с переключателем. Параметр читается один раз при загрузке (src/App.tsx), в контракте postMessage ничего не меняется — diagram:init/diagram:change работают одинаково в обоих режимах.
Зачем: встраивающая сторона, которая не слушает diagram:change (например, страница портала, показывающая утверждённую схему), иначе предлагала бы правки, которые молча никуда не сохраняются.
5. Требования безопасности (обязательны, не опциональны)
- Значение всегда попадает на страницу только через
value-атрибут hidden input (htmlspecialcharsbx($rawValue), см. раздел 3), не через встраивание в инлайн-скрипт — так JSON-содержимое никогда не оказывается внутри JS-исходника, иJSON_HEX_*-экранирование не требуется вообще. Мост читаетinput.valueнапрямую в JS. Если по каким-то причинам всё же понадобится встроить значение в инлайн-скрипт (например, отдельныйinitialValueвместо чтения из DOM) — тогда обязателенJSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOTприjson_encode. event.originпроверяется в обе стороны: и в мосте (когда он слушает сообщения от iframe), и в приложении (уже реализовано во frontend).targetOriginу любогоpostMessage— всегда конкретный домен (appOrigin), никогда'*'.nameу hidden input должен точно совпадать с$strHTMLControlName['VALUE']— это единственная связь между кастомным рендером и штатным сохранением формы.
6. Формат JSON диаграммы
Пример реального значения свойства (то, что лежит в hidden input и уходит в ConvertToDB):
{
"nodes": [
{
"id": "start",
"type": "startEvent",
"position": { "x": 0, "y": 80 },
"width": 48,
"height": 48,
"data": { "label": "Заявка получена" }
},
{
"id": "review",
"type": "task",
"position": { "x": 220, "y": 60 },
"width": 140,
"height": 50,
"data": {
"label": "Приём заявок",
"description": "Проводится ответственным лицом, 1-2 дня.",
"responsible": "Иван Иванов",
"duration": "1-2 дня",
"color": "#eef2ff"
},
"parentId": "some-other-node-id",
"zIndex": 1
}
],
"edges": [
{
"id": "start-review",
"source": "start",
"target": "review",
"sourceHandle": "right",
"targetHandle": "left",
"label": "Да"
}
]
}
Пояснения по полям (все, кроме перечисленных обязательными, — опциональны и могут отсутствовать):
nodes[].id/edges[].id— уникальные строки, обязательны.nodes[].type— один из:task,startEvent,intermediateEvent,endEvent,exclusiveGateway,parallelGateway,inclusiveGateway.nodes[].position— координаты на холсте, обязательны.nodes[].width/height— размер фигуры (пользователь может менять руками).nodes[].data.label— название, обязательно; остальные поляdata— произвольные метаданные, отображаемые в форме редактирования.nodes[].parentId— если узел вложен в другой (drill-down «под-процесс» внутри той же диаграммы, не переход на другой элемент).nodes[].zIndex/edges[].zIndex— порядок наложения друг на друга.edges[].source/target— id узлов, обязательны.edges[].sourceHandle/targetHandle— с какой стороны фигуры выходит/входит линия (top/right/bottom/left).
Backend не обязан ничего валидировать глубже «это либо пустая строка, либо синтаксически корректный JSON» — вся содержательная валидация (обязательные поля и т.п.) — на стороне приложения.
7. Деплой приложения
Два варианта. Вариант A проще и ближе к тому, как уже устроен сам портал — рекомендуется. Вариант B (отдельный контейнер) остаётся как альтернатива на случай, если приложение когда-нибудь понадобится хостить отдельно от Bitrix-стенда (другой домен, отдельный CI/CD и т.п.).
Вариант A (рекомендуется) — статика прямо в дереве Bitrix
Проверено на текущем дев-стенде (portal-bitrix-docker): каталог сайта — не часть Docker-образа, а volume bitrix_www, смонтированный в контейнеры nginx и php как bind-mount на каталог хоста (docker volume inspect bitrix_www → device: ..., o: bind). Именно так уже хостится сам портал: файлы лежат на диске хоста, штатный nginx их и раздаёт — значит собранную статику приложения можно положить туда же, без отдельного контейнера.
Что это даёт технически:
-
Nginx-конфиг стенда отдаёт файлы по расширению (
js|css|html|svg|png|...) напрямую с диска — это срабатывает раньше, чем запрос попадёт в PHP-роутер Bitrix. Любой статический файл под корнем сайта отдаётся сам, никаких правок nginx не требуется. -
local/уже существует как каталог для кастомного кода (ai,components,modules,php_interface,templates— по факту на дев-стенде). Новый подкаталог, напримерlocal/diagram-app/, вписывается в эту конвенцию и не пересекается сlocal/modules/local/php_interface— они у nginx явно помеченыinternal(заблокированы для прямого внешнего доступа), новый подкаталог под этот блок не попадает. -
Нюанс:
indexна уровне сервера —index.php, неindex.html. Открыть голый/local/diagram-app/(без имени файла) не выйдет — nginx попытается найти тамindex.phpи не найдёт. PHP-класс (раздел 3) сам подставляетsrcу iframe — но, в отличие от более ранней версии этого документа, больше нельзя просто написать/local/diagram-app/index.htmlстрокой — см. следующий пункт. -
Так как приложение и портал теперь на одном домене,
VITE_BITRIX_ORIGIN/appOrigin— это просто origin самого портала, а не адрес отдельного сервиса. -
Имена файлов в
dist/— с хешем содержимого, включая самindex.html.index.js/index.css/шрифты хеширует сам Vite при сборке (стандартное поведение, как в любом Vite-проекте). Кроме того, вvite.config.tsесть небольшой плагин (hashIndexHtml), который дополнительно хеширует и самindex.html— на выходе получается что-то вродеindex-2fddeca6.html, а не простоindex.html. Имя меняется всякий раз, когда меняется что-то значимое (сама HTML-обёртка либо то, что она подключает), и не меняется при пересборке без реальных изменений.Из этого следует главное отличие от более ранней версии данного документа:
$appUrlв PHP-классе (раздел 3) больше нельзя хардкодить как строку — конкретное имяindex-<hash>.htmlменяется при каждой пересборке приложения. Бэкенду нужен свой способ узнавать актуальное имя файла на момент рендера страницы (например, взять единственныйindex-*.htmlиз каталога деплоя, или через отдельный метод/эндпоинт, который вернёт актуальное имя, — механизм на усмотрение бэкенда, здесь не фиксирован, уточняйте на момент внедрения).Готовый механизм —
app-manifest.jsрядом с билдом. Тот же плагинhashIndexHtmlкладёт вdist/файл со стабильным, никогда не меняющимся именем:{ "index": "index-2fddeca6.html" }Его можно прочитать как с бэкенда при рендере страницы, так и на клиенте —
fetch('<каталог>/app-manifest.js', { cache: 'no-store' }), — и уже к полученному имени дописывать?mode=view.no-storeздесь обязателен: имя файла стабильно, значит сам манифест попадает под тот же 30-дневныйCache-Control, что и остальная статика. Этим сейчас пользуется portal-front (src/helpers/chart-builder.ts), пока у бэка нет своего метода.Взамен это полностью снимает необходимость в отдельном cache-busting query-параметре (
?v=...) — раньше он был обязателен и для самогоindex.html, и (до появления хеша у самого файла) дляindex.js/index.cssвнутри него. Теперь URL меняется вместе с содержимым: старый закэшированныйindex-OLDHASH.htmlпросто никогда больше не запрашивается, как только хост узнаёт новое имя — что убирает разом оба уровня кэш-проблемы, описанной в более ранней версии этого раздела.
Порядок действий:
- В репозитории
portal-chart-builderсобрать статику с двумя build-time переменными:Переменные окружения не нужны.npm run buildVITE_BITRIX_ORIGINв этом варианте не задаётся — см. примечание в конце раздела. Пути к JS/CSS внутриindex.htmlотносительные (base: './'вvite.config.ts), поэтому один и тот жеdist/работает из любого каталога размещения — раньше это задавалось переменнойVITE_BASE_PATH, которая должна была точно совпадать с каталогом деплоя и при расхождении давала пустой iframe (html грузится, его JS уходит в 404). - Скопировать содержимое
dist/вlocal/diagram-app/внутри host-каталога, который смонтирован какbitrix_www(на текущем дев-стенде это/home/nikit/bitrix/www/local/diagram-app/— точный путь для конкретного стенда уточняется у того, кто его поднимал/администрирует). - В PHP-классе (раздел 3):
$appUrl = $appOrigin . '/local/diagram-app/' . $actualIndexFileName;,$appOrigin— origin портала (без пути),$actualIndexFileName— актуальное имяindex-<hash>.htmlвlocal/diagram-app/, полученное способом на усмотрение бэкенда (см. врезку про кэш выше — хардкодитьindex.htmlстрокой уже нельзя). - Обновление после правок в React-приложении — тот же
npm run build+ копирование поверх старых файлов. Пересборка/перезапуск контейнеров Bitrix не нужны. Старыйindex-<hash>.htmlот предыдущей сборки при этом не удаляется автоматически — если способ поиска актуального файла (шаг 3) предполагает «взять единственный файл по маске», после нескольких деплоев в каталоге стоит убирать файлы прошлых версий, иначе маска перестанет быть однозначной.
Вариант B — отдельный Docker-контейнер
В репозитории portal-chart-builder есть Dockerfile (multi-stage: сборка через node, раздача статики через nginx) и .dockerignore — для случая, если приложение должно жить отдельно от Bitrix-стенда (свой домен, свой деплой).
Ключевое рассуждение: iframe грузит браузер администратора, а не сервер Bitrix — запроса «сервер → приложение» не происходит вообще. Значит, если контейнер с приложением опубликован портом на тот же хост, откуда админ открывает Bitrix (текущий дев-стенд: это одна и та же машина), достаточно сослаться на http://localhost:<порт> — отдельный домен или туннель не нужны.
Пример сервиса для добавления в docker-compose.yml Bitrix-стенда (иллюстративный — правки самого файла делает тот, кто ведёт стенд):
services:
diagram-app:
build:
context: ../portal-chart-builder
args:
VITE_BITRIX_ORIGIN: http://portal.local # origin самого Bitrix-стенда
container_name: portal_diagram_app
restart: unless-stopped
ports:
- "5178:80" # публикуем на хост
Тогда:
VITE_BITRIX_ORIGIN(build-arg, см. раздел 5) = origin Bitrix-портала —http://portal.local.$appUrl/$appOriginв PHP-классе (раздел 3) = адрес самого приложения —http://localhost:5178.
Как передать сам аргумент:
- Через docker-compose (как в примере выше) — значение прописывается в
build.argsсервиса; приdocker compose up --build/docker compose buildcompose сам подставит его в сборку, никаких дополнительных флагов не нужно. - Через
docker buildнапрямую (ручная сборка, например для локальной проверки) — флагом--build-arg:docker build --build-arg VITE_BITRIX_ORIGIN=http://portal.local -t portal-diagram-app .— именно так проверялось выше. - Чтобы не хардкодить реальный адрес прямо в
docker-compose.yml(он попадёт в git) — вынести значение в.env-файл рядом сdocker-compose.yml(VITE_BITRIX_ORIGIN=http://portal.local) и сослаться на него в compose какVITE_BITRIX_ORIGIN: ${VITE_BITRIX_ORIGIN}— Compose сам подхватывает.envиз той же папки.
Собрал и проверил локально (docker build + docker run -p 5178:80) — контейнер поднимается и корректно отдаёт приложение по http://localhost:5178.
Важно (общее для обоих вариантов): VITE_BITRIX_ORIGIN — build-time переменная, вшивается в JS-бандл на этапе npm run build/docker build, не читается заново при каждом запуске/обновлении.
В варианте A она не нужна: приложение и портал на одном домене, и origin хоста приложение берёт из window.location.origin. Более того, заданная переменная там вредна — она перебьёт рантайм-значение, и на любом домене, кроме зашитого, обмен молча не состоится. Нужна она только в варианте B (или при локальной разработке), где хост и приложение на разных origin. Требование «никогда postMessage('*')» соблюдается в обоих случаях: targetOrigin всегда конкретный origin.
Схема с localhost (вариант B) работает, пока браузер администратора и Docker-хост — одна машина (текущий дев-стенд). Если позже появится реальный удалённый прод с админами не с этой машины — понадобится либо настоящий домен, либо reverse-proxy; это отдельная, более поздняя задача. Вариант A этой проблемы не имеет вообще, так как всегда живёт на домене портала.
8. Открытые решения (нужно определить до/во время внедрения)
- Конкретный инфоблок, к которому подключается свойство, — не выбран, определяется пользователем/владельцем задачи отдельно. Код из раздела 3 не зависит от конкретного инфоблока — тип свойства регистрируется глобально и включается точечно через админку (см. раздел 2).
- Публичная часть сайта — решено, что в первой версии не нужна (только админка). React-приложение уже поддерживает read-only «режим просмотра» — если публичная часть понадобится позже, доработка небольшая (реализовать
GetPublicViewHTML, встроить тот же iframe в режиме просмотра).
9. Definition of done
- Открыть страницу редактирования элемента (для которого включено свойство) — поле показывает iframe с пустой диаграммой (для нового элемента) или с ранее сохранённой.
- Отредактировать диаграмму (подвигать фигуры, поменять текст).
- Нажать штатную кнопку «Сохранить» на странице элемента (не внутри iframe).
- Перезагрузить страницу — изменения на месте.
- Открыть значение свойства напрямую в БД/через API инфоблоков — там лежит валидный JSON вида из раздела 6.