Подготовил к интеграции с битриксом

This commit is contained in:
NikitaKalitov
2026-07-16 16:23:08 +03:00
parent be2504d60d
commit b09bd6abf7
9 changed files with 374 additions and 13 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
dist-ssr
.git
docs
+14
View File
@@ -0,0 +1,14 @@
# Static build served by nginx. VITE_BITRIX_ORIGIN must be provided at build
# time (build arg) — it gets baked into the JS bundle, see docs/bitrix-integration.md §7.
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ARG VITE_BITRIX_ORIGIN
ENV VITE_BITRIX_ORIGIN=$VITE_BITRIX_ORIGIN
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+261
View File
@@ -0,0 +1,261 @@
# Интеграция с 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`:
```php
AddEventHandler("iblock", "OnIBlockPropertyBuildList", ["DiagramProperty", "GetUserTypeDescription"]);
```
После регистрации новый тип появляется в **Контент → Инфоблоки → [нужный] → Свойства → Тип свойства**. Свойство должно быть **одиночным** (`MULTIPLE = N`) — одна диаграмма на элемент.
## 3. Скелет PHP-класса
Ниже — иллюстративный каркас, не готовый к продакшену код. Реализация валидации, экранирования и т.д. — на усмотрение разработчика, но с обязательным соблюдением требований из раздела 5.
```php
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'] ?? '';
// JSON_HEX_* флаги обязательны — см. раздел 5.
$jsValue = json_encode($rawValue, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT);
// Вариант A — рекомендуемый, приложение лежит внутри дерева портала (см. раздел 7):
// appOrigin — это просто origin текущего запроса, appUrl — конкретный файл.
$appOrigin = (CMain::IsHTTPS() ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'];
$appUrl = $appOrigin . '/local/diagram-app/index.html';
// Вариант 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 () {
var appOrigin = <?= json_encode($appOrigin) ?>;
var initialValue = <?= $jsValue ?>; // строка JSON или ""
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: initialValue || 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` (или пустой строкой) — валидный случай: новый элемент без диаграммы, приложение стартует с чистого холста.
## 5. Требования безопасности (обязательны, не опциональны)
- При встраивании `initialValue` в инлайн-скрипт — **обязательно** `JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT` (см. пример в разделе 3). Без этого — риск XSS через выход из `<script>`-тега при вредоносном содержимом свойства.
- `event.origin` проверяется **в обе стороны**: и в мосте (когда он слушает сообщения от iframe), и в приложении (уже реализовано во frontend).
- `targetOrigin` у любого `postMessage` — всегда конкретный домен (`appOrigin`), **никогда `'*'`**.
- `name` у hidden input должен **точно** совпадать с `$strHTMLControlName['VALUE']` — это единственная связь между кастомным рендером и штатным сохранением формы.
## 6. Формат JSON диаграммы
Пример реального значения свойства (то, что лежит в hidden input и уходит в `ConvertToDB`):
```json
{
"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, поэтому в `$appUrl` достаточно явно указывать файл — `/local/diagram-app/index.html`.
- Так как приложение и портал теперь на одном домене, `VITE_BITRIX_ORIGIN`/`appOrigin` — это просто origin самого портала, а не адрес отдельного сервиса.
Порядок действий:
1. В репозитории `portal-chart-builder` собрать статику с двумя build-time переменными:
```
VITE_BITRIX_ORIGIN=https://ВАШ-ДОМЕН-ПОРТАЛА VITE_BASE_PATH=/local/diagram-app/ npm run build
```
`VITE_BASE_PATH` — путь, под которым будет отдаваться приложение (нужен, чтобы ссылки на JS/CSS внутри `dist/index.html` были корректными под этим подпутём; см. `vite.config.ts`). По умолчанию, если переменная не задана, `base` — `/` (не влияет на локальную разработку, `npm run dev`).
2. Скопировать содержимое `dist/` в `local/diagram-app/` внутри host-каталога, который смонтирован как `bitrix_www` (на текущем дев-стенде это `/home/nikit/bitrix/www/local/diagram-app/` — точный путь для конкретного стенда уточняется у того, кто его поднимал/администрирует).
3. В PHP-классе (раздел 3): `$appUrl = $appOrigin . '/local/diagram-app/index.html';`, `$appOrigin` — origin портала (без пути).
4. Обновление после правок в React-приложении — тот же `npm run build` + копирование поверх старых файлов. Пересборка/перезапуск контейнеров Bitrix не нужны.
### Вариант B — отдельный Docker-контейнер
В репозитории `portal-chart-builder` есть `Dockerfile` (multi-stage: сборка через node, раздача статики через nginx) и `.dockerignore` — для случая, если приложение должно жить отдельно от Bitrix-стенда (свой домен, свой деплой).
Ключевое рассуждение: iframe грузит **браузер администратора**, а не сервер Bitrix — запроса «сервер → приложение» не происходит вообще. Значит, если контейнер с приложением опубликован портом на тот же хост, откуда админ открывает Bitrix (текущий дев-стенд: это одна и та же машина), достаточно сослаться на `http://localhost:<порт>` — отдельный домен или туннель не нужны.
Пример сервиса для добавления в `docker-compose.yml` Bitrix-стенда (иллюстративный — правки самого файла делает тот, кто ведёт стенд):
```yaml
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 build` compose сам подставит его в сборку, никаких дополнительных флагов не нужно.
- **Через `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`, не читается заново при каждом запуске/обновлении. Без неё приложение намеренно упадёт при загрузке (см. раздел 5) — чтобы нельзя было собрать билд с `postMessage('*')`.
Схема с `localhost` (вариант B) работает, пока браузер администратора и Docker-хост — одна машина (текущий дев-стенд). Если позже появится реальный удалённый прод с админами не с этой машины — понадобится либо настоящий домен, либо reverse-proxy; это отдельная, более поздняя задача. Вариант A этой проблемы не имеет вообще, так как всегда живёт на домене портала.
## 8. Открытые решения (нужно определить до/во время внедрения)
- **Конкретный инфоблок**, к которому подключается свойство, — не выбран, определяется пользователем/владельцем задачи отдельно. Код из раздела 3 не зависит от конкретного инфоблока — тип свойства регистрируется глобально и включается точечно через админку (см. раздел 2).
- **Публичная часть сайта** — решено, что в первой версии не нужна (только админка). React-приложение уже поддерживает read-only «режим просмотра» — если публичная часть понадобится позже, доработка небольшая (реализовать `GetPublicViewHTML`, встроить тот же iframe в режиме просмотра).
## 9. Definition of done
1. Открыть страницу редактирования элемента (для которого включено свойство) — поле показывает iframe с пустой диаграммой (для нового элемента) или с ранее сохранённой.
2. Отредактировать диаграмму (подвигать фигуры, поменять текст).
3. Нажать штатную кнопку «Сохранить» на странице элемента (не внутри iframe).
4. Перезагрузить страницу — изменения на месте.
5. Открыть значение свойства напрямую в БД/через API инфоблоков — там лежит валидный JSON вида из раздела 6.
+17 -5
View File
@@ -1,7 +1,8 @@
import { useCallback, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { DiagramEditor } from './DiagramEditor'; import { DiagramEditor } from './DiagramEditor';
import { createDevAdapter } from './host/devAdapter'; import { createDevAdapter } from './host/devAdapter';
import type { DiagramDocument } from './types'; import { createBitrixAdapter } from './host/bitrixAdapter';
import { EMPTY_DOCUMENT, type DiagramDocument } from './types';
const SAMPLE_DOCUMENT: DiagramDocument = { const SAMPLE_DOCUMENT: DiagramDocument = {
nodes: [ nodes: [
@@ -62,16 +63,27 @@ const SAMPLE_DOCUMENT: DiagramDocument = {
], ],
}; };
const adapter = createDevAdapter(); const isProd = import.meta.env.PROD;
const adapter = isProd ? createBitrixAdapter() : createDevAdapter();
// In Bitrix, no value means a brand-new element — start blank, not with dev demo content.
const fallbackDocument = isProd ? EMPTY_DOCUMENT : SAMPLE_DOCUMENT;
function App() { function App() {
const [initialDoc] = useState<DiagramDocument>(() => adapter.getInitialValue() ?? SAMPLE_DOCUMENT); const [doc, setDoc] = useState<DiagramDocument | null>(null);
useEffect(() => {
adapter.onReady((initial) => setDoc(initial ?? fallbackDocument));
}, []);
const handleChange = useCallback((next: DiagramDocument) => { const handleChange = useCallback((next: DiagramDocument) => {
adapter.onChange(next); adapter.onChange(next);
}, []); }, []);
return <DiagramEditor initialValue={initialDoc} onChange={handleChange} />; if (!doc) {
return <p style={{ padding: 16, fontFamily: 'system-ui, sans-serif' }}>Загрузка диаграммы…</p>;
}
return <DiagramEditor initialValue={doc} onChange={handleChange} />;
} }
export default App; export default App;
+4 -4
View File
@@ -2,11 +2,11 @@ import type { DiagramDocument } from '../types';
/** /**
* Seam between the diagram app and whatever embeds it. * Seam between the diagram app and whatever embeds it.
* Dev builds read/write localStorage; the Bitrix build will read the * `onReady` is callback-based rather than a synchronous getter because the
* initial value from the bridged hidden input and push changes back * Bitrix bridge only has the initial value after a postMessage round trip
* to it (see bitrix-diagram-constructor-summary.md). * (see docs/bitrix-integration.md) — dev builds just call back immediately.
*/ */
export interface HostAdapter { export interface HostAdapter {
getInitialValue: () => DiagramDocument | null; onReady: (callback: (doc: DiagramDocument | null) => void) => void;
onChange: (doc: DiagramDocument) => void; onChange: (doc: DiagramDocument) => void;
} }
+56
View File
@@ -0,0 +1,56 @@
import type { DiagramDocument } from '../types';
import type { HostAdapter } from './HostAdapter';
/**
* postMessage handshake with the Bitrix bridge script (see
* docs/bitrix-integration.md for the PHP side). `targetOrigin` is always a
* concrete origin, never '*' — matches the security requirement from the
* original design doc.
*/
const READY_MESSAGE = 'diagram:ready';
const INIT_MESSAGE = 'diagram:init';
const CHANGE_MESSAGE = 'diagram:change';
function getBitrixOrigin(): string {
const origin = import.meta.env.VITE_BITRIX_ORIGIN;
if (!origin) {
throw new Error(
'VITE_BITRIX_ORIGIN is not set. The bridge needs a concrete parent origin — postMessage must never target "*".',
);
}
return origin;
}
export function createBitrixAdapter(): HostAdapter {
const targetOrigin = getBitrixOrigin();
return {
onReady(callback) {
let delivered = false;
function onMessage(event: MessageEvent) {
if (event.origin !== targetOrigin || event.source !== window.parent) return;
if (delivered || event.data?.type !== INIT_MESSAGE) return;
delivered = true;
window.removeEventListener('message', onMessage);
const payload = event.data.payload as string | null | undefined;
if (!payload) {
callback(null);
return;
}
try {
callback(JSON.parse(payload) as DiagramDocument);
} catch {
callback(null);
}
}
window.addEventListener('message', onMessage);
window.parent.postMessage({ type: READY_MESSAGE }, targetOrigin);
},
onChange(doc) {
window.parent.postMessage({ type: CHANGE_MESSAGE, payload: JSON.stringify(doc) }, targetOrigin);
},
};
}
+7 -4
View File
@@ -5,13 +5,16 @@ const STORAGE_KEY = 'diagram-builder:dev-doc';
export function createDevAdapter(): HostAdapter { export function createDevAdapter(): HostAdapter {
return { return {
getInitialValue() { onReady(callback) {
const raw = localStorage.getItem(STORAGE_KEY); const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null; if (!raw) {
callback(null);
return;
}
try { try {
return JSON.parse(raw) as DiagramDocument; callback(JSON.parse(raw) as DiagramDocument);
} catch { } catch {
return null; callback(null);
} }
}, },
onChange(doc) { onChange(doc) {
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
/** Bitrix portal origin the postMessage bridge is allowed to talk to (e.g. https://portal.local). Required in production builds. */
readonly VITE_BITRIX_ORIGIN?: string;
}
+4
View File
@@ -3,5 +3,9 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
// Needed when the build is served from a subpath (e.g. local/diagram-app/
// inside the Bitrix docroot) instead of domain root — see
// docs/bitrix-integration.md §7.
base: process.env.VITE_BASE_PATH || '/',
plugins: [react()], plugins: [react()],
}) })