feat(admin): add visibility controls and link modes

This commit is contained in:
dcconstructions 2026-06-27 13:22:16 +03:00
parent 26f9e5daeb
commit 12493711d1
8 changed files with 474 additions and 134 deletions

View File

@ -1017,6 +1017,60 @@ body[data-file-picker-active="true"] .editor {
font-weight: 850;
}
.link-mode-field {
gap: 0.48rem;
}
.link-mode-grid {
display: grid;
gap: 0.48rem;
}
.link-mode-row {
display: grid;
grid-template-columns: auto minmax(4.5rem, 0.22fr) minmax(0, 1fr);
min-width: 0;
align-items: center;
gap: 0.55rem;
border-radius: var(--launcher-radius-control);
background: var(--field);
padding: 0.42rem 0.6rem;
color: var(--text-secondary);
}
.link-mode-row input[type="checkbox"] {
width: 1.18rem;
height: 1.18rem;
margin: 0;
appearance: none;
border: 0.18rem solid rgba(255, 255, 255, 0.84);
border-radius: 50%;
background: transparent;
}
.link-mode-row input[type="checkbox"]:checked {
box-shadow: inset 0 0 0 0.22rem #050506;
background: rgba(255, 255, 255, 0.94);
}
.link-mode-row span {
overflow: hidden;
color: var(--text-muted);
font-size: 0.68rem;
font-weight: 850;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.link-mode-row input[type="text"] {
min-width: 0;
min-height: 2rem;
border-radius: 0.78rem;
background: rgba(255, 255, 255, 0.04);
padding: 0 0.72rem;
}
.boolean-field input,
.group-head-toggle input,
.collection-visibility-toggle input {

View File

@ -10,6 +10,7 @@ const state = {
mediaUploadActive: false,
filePickerResetTimer: null,
deleteConfirmResolve: null,
collectionOpenState: new Map(),
};
const SECTIONS_REGION_ID = "sections";
@ -62,26 +63,36 @@ const STATIC_FALLBACK = {
logoAlt: "главная",
brandLabel: "NODE.DC",
links: [
{ label: "О продукте", href: "index.html#features", target: "" },
{ label: "Лист ожидания", href: "index.html#hero", target: "" },
{ label: "Документация", href: "https://docs.ssscript.app/", target: "_blank" },
{ label: "Тарифы", href: "index.html#pricing-wrap", target: "" },
{ enabled: true, label: "О продукте", href: "", target: "index.html#features", linkMode: "target", linkTarget: "" },
{ enabled: true, label: "Лист ожидания", href: "", target: "index.html#hero", linkMode: "target", linkTarget: "" },
{
enabled: true,
label: "Документация",
href: "https://docs.ssscript.app/",
target: "",
linkMode: "href",
linkTarget: "_blank",
},
{ enabled: true, label: "Тарифы", href: "", target: "index.html#pricing-wrap", linkMode: "target", linkTarget: "" },
],
},
notifications: [
{
enabled: true,
eyebrowHtml: '<span class="darker-70">ДАЛЕЕ</span> — ИЮЛЬ 2026',
eyebrowClass: "red-txt gray",
title: "Открытая бета",
body: "Открываем доступ всем. Будем собирать и отлаживать вместе.",
},
{
enabled: true,
eyebrowHtml: '<span class="darker-70">ДАЛЕЕ</span> —<span class="darker-70"> </span>ИЮЛЬ 2026',
eyebrowClass: "red-txt gray",
title: "Альфа для Founder-доступа",
body: "На второй фазе тестирования бета открыта для всех подписчиков Founder-тарифа.",
},
{
enabled: true,
eyebrowHtml: "СЕЙЧАС",
eyebrowClass: "red-txt",
title: "Инвайты в закрытую бету уже отправлены.",
@ -135,11 +146,10 @@ const STATIC_FALLBACK = {
"content.navigation.links",
"Кнопки шапки",
"Шапка: ссылки",
{ label: "Новая ссылка", href: "index.html#", target: "" },
{ enabled: true, label: "Новая ссылка", href: "", target: "index.html#", linkMode: "target", linkTarget: "" },
[
field("label", "Текст кнопки", "text", "text", "Шапка: ссылки"),
field("href", "Адрес", "url", "attr", "Шапка: ссылки"),
field("target", "Target", "text", "attr", "Шапка: ссылки"),
field("linkMode", "Источник ссылки", "link-mode", "attr", "Шапка: ссылки"),
],
),
collection(
@ -147,6 +157,7 @@ const STATIC_FALLBACK = {
"Уведомления",
"Уведомления",
{
enabled: true,
eyebrowHtml: "СЕЙЧАС",
eyebrowClass: "red-txt gray",
title: "Новое уведомление",
@ -235,6 +246,8 @@ const GROUP_COPY = {
const MEDIA_ACCEPT = "image/*,video/*,.gif,.webm,.mov,.mp4,.m4v,.avi,.mkv,.glb,.gltf,.svg,.avif,.webp,.woff,.woff2";
const mediaFilePicker = createMediaFilePicker();
const collectionItemRuntimeKeys = new WeakMap();
let collectionItemRuntimeKeyCounter = 0;
function field(path, label, kind = "text", renderAs = kind === "html" ? "html" : "text", group = null, options = {}) {
return { path, label, kind, renderAs, group: group || inferFieldGroup(path), ...options };
@ -332,6 +345,48 @@ function setByPath(source, path, nextValue) {
target[lastKey] = nextValue;
}
const LINK_WINDOW_TARGETS = new Set(["_blank", "_self", "_parent", "_top"]);
function isWindowTarget(value) {
return LINK_WINDOW_TARGETS.has(String(value || "").trim());
}
function isExternalHref(value) {
return /^(https?:)?\/\//i.test(String(value || "")) || /^(mailto:|tel:)/i.test(String(value || ""));
}
function normalizeChoiceLink(item, fallback = {}) {
if (!item || typeof item !== "object") return;
let href = item.href ?? fallback.href ?? "";
let target = item.target ?? fallback.target ?? "";
let linkTarget = item.linkTarget ?? fallback.linkTarget ?? "";
if (isWindowTarget(target)) {
linkTarget ||= target;
target = "";
}
const inferredMode = isExternalHref(href) ? "href" : "target";
const linkMode = item.linkMode === "href" || item.linkMode === "target" ? item.linkMode : inferredMode;
if (linkMode === "target" && !target && href && !isExternalHref(href)) {
target = href;
href = "";
}
item.enabled = item.enabled !== false;
item.href = href;
item.target = target;
item.linkMode = linkMode;
item.linkTarget = linkTarget;
}
function normalizeChoiceLinks(items, fallbacks = []) {
if (!Array.isArray(items)) return;
items.forEach((item, index) => normalizeChoiceLink(item, fallbacks[index] || fallbacks[0] || {}));
}
function inferFieldGroup(path) {
if (path.startsWith("content.navigation.")) return "Шапка";
if (path.startsWith("content.notifications.")) return "Уведомления";
@ -433,8 +488,10 @@ function ensureStaticElements() {
state.page.staticElements.content.navigation ||= clone(STATIC_FALLBACK.content.navigation);
state.page.staticElements.content.navigation.logoSrc ||= STATIC_FALLBACK.content.navigation.logoSrc;
state.page.staticElements.content.navigation.links ||= clone(STATIC_FALLBACK.content.navigation.links);
normalizeChoiceLinks(state.page.staticElements.content.navigation.links, STATIC_FALLBACK.content.navigation.links);
state.page.staticElements.content.notifications ||= clone(STATIC_FALLBACK.content.notifications);
state.page.staticElements.content.notifications.forEach((notification, index) => {
notification.enabled = notification.enabled !== false;
notification.eyebrowClass ||= STATIC_FALLBACK.content.notifications[index]?.eyebrowClass || "red-txt gray";
});
state.page.staticElements.content.assets ||= clone(STATIC_FALLBACK.content.assets);
@ -1467,6 +1524,42 @@ function prepareCollectionItemForInsert(items, item) {
return nextItem;
}
function collectionInstanceKey(block, editableField) {
return `${block?.id || "static-elements"}::${editableField.path}`;
}
function collectionItemKey(item, index) {
if (item && typeof item === "object") {
if (item.id) return `id:${item.id}`;
let runtimeKey = collectionItemRuntimeKeys.get(item);
if (!runtimeKey) {
collectionItemRuntimeKeyCounter += 1;
runtimeKey = `runtime:${collectionItemRuntimeKeyCounter}`;
collectionItemRuntimeKeys.set(item, runtimeKey);
}
return runtimeKey;
}
return `value:${index}:${String(item)}`;
}
function collectionOpenKeys(block, editableField, items) {
const key = collectionInstanceKey(block, editableField);
let openKeys = state.collectionOpenState.get(key);
if (!openKeys) {
openKeys = new Set();
if (items.length > 0 && items.length <= 8) {
openKeys.add(collectionItemKey(items[0], 0));
}
state.collectionOpenState.set(key, openKeys);
}
return openKeys;
}
function fieldInputValue(input) {
if (input.type === "checkbox") return input.checked;
@ -1618,6 +1711,91 @@ function renderNumberField({ block, editableField, grid }) {
grid.append(label);
}
function siblingFieldPath(path, siblingKey) {
const keys = path.split(".");
keys[keys.length - 1] = siblingKey;
return keys.join(".");
}
function renderLinkModeField({ block, field, grid }) {
const container = document.createElement("div");
const labelRow = document.createElement("div");
const labelText = document.createElement("span");
const kind = document.createElement("span");
const hiddenMode = document.createElement("input");
const rows = document.createElement("div");
const path = document.createElement("span");
const hrefPath = siblingFieldPath(field.path, field.hrefPath || "href");
const targetPath = siblingFieldPath(field.path, field.targetPath || "target");
const currentMode = getByPath(block, field.path) === "href" ? "href" : "target";
function createRow({ mode, title, inputPath, placeholder }) {
const row = document.createElement("label");
const check = document.createElement("input");
const caption = document.createElement("span");
const input = document.createElement("input");
row.className = "link-mode-row";
check.type = "checkbox";
check.checked = currentMode === mode;
check.setAttribute("aria-label", `${title}: использовать`);
caption.textContent = title;
input.dataset.path = inputPath;
input.type = "text";
input.autocomplete = "off";
input.placeholder = placeholder;
input.value = getByPath(block, inputPath) ?? "";
check.addEventListener("change", () => {
if (!check.checked) {
check.checked = true;
return;
}
const active = activeBlock();
if (!active) return;
hiddenMode.value = mode;
setByPath(active, field.path, mode);
rows.querySelectorAll('input[type="checkbox"]').forEach((candidate) => {
candidate.checked = candidate === check;
});
el.json.value = JSON.stringify(active, null, 2);
markDirty(mode === "href" ? "Активен внешний адрес." : "Активен Target.");
});
input.addEventListener("input", () => {
const active = activeBlock();
if (!active) return;
syncFieldValue(active, inputPath, input);
});
row.append(check, caption, input);
return row;
}
container.className = "field-control wide link-mode-field";
labelRow.className = "field-label-row";
labelText.className = "field-label-text";
labelText.textContent = field.label || "Источник ссылки";
kind.className = "field-kind";
kind.textContent = "link";
labelRow.append(labelText, kind);
hiddenMode.type = "hidden";
hiddenMode.dataset.path = field.path;
hiddenMode.value = currentMode;
rows.className = "link-mode-grid";
rows.append(
createRow({ mode: "href", title: "Адрес", inputPath: hrefPath, placeholder: "https://..." }),
createRow({ mode: "target", title: "Target", inputPath: targetPath, placeholder: "index.html#section" }),
);
path.className = "field-path";
path.textContent = `${field.path} · ${hrefPath} · ${targetPath}`;
container.append(labelRow, hiddenMode, rows, path);
grid.append(container);
}
function renderHeaderBooleanToggle({ block, editableField, head }) {
const label = document.createElement("label");
const input = document.createElement("input");
@ -1640,6 +1818,11 @@ function renderHeaderBooleanToggle({ block, editableField, head }) {
}
function renderCollectionScalarField({ block, field, grid }) {
if (field.kind === "link-mode") {
renderLinkModeField({ block, field, grid });
return;
}
if (field.kind === "boolean") {
renderBooleanField({ block, editableField: field, grid });
return;
@ -1752,6 +1935,7 @@ function startCollectionPointerDrag(event, card, items, index, rerender) {
function renderCollectionField({ block, editableField, grid }) {
const items = collectionItems(block, editableField);
const openKeys = collectionOpenKeys(block, editableField, items);
const container = document.createElement("section");
const head = document.createElement("div");
const title = document.createElement("div");
@ -1774,7 +1958,9 @@ function renderCollectionField({ block, editableField, grid }) {
addButton.setAttribute("aria-label", addButton.title);
addButton.textContent = "+";
addButton.addEventListener("click", () => {
items.push(prepareCollectionItemForInsert(items, collectionItemTemplate(editableField)));
const nextItem = prepareCollectionItemForInsert(items, collectionItemTemplate(editableField));
items.push(nextItem);
openKeys.add(collectionItemKey(nextItem, items.length - 1));
rerender(`Добавлен элемент в «${editableField.label || editableField.path}».`);
});
head.append(title, addButton);
@ -1782,6 +1968,7 @@ function renderCollectionField({ block, editableField, grid }) {
list.className = "collection-list";
items.forEach((item, index) => {
const itemKey = collectionItemKey(item, index);
const details = document.createElement("details");
const summary = document.createElement("summary");
const summaryTitle = document.createElement("span");
@ -1797,7 +1984,11 @@ function renderCollectionField({ block, editableField, grid }) {
details.className = "collection-item-card";
details.classList.toggle("is-hidden", item?.enabled === false);
if (index === 0 && items.length <= 8) details.open = true;
details.open = openKeys.has(itemKey);
details.addEventListener("toggle", () => {
if (details.open) openKeys.add(itemKey);
else openKeys.delete(itemKey);
});
summary.className = "collection-summary";
summaryTitle.className = "collection-summary-title";
summaryTitle.textContent = collectionItemTitle(item, index);
@ -1851,12 +2042,15 @@ function renderCollectionField({ block, editableField, grid }) {
rerender("Порядок элементов коллекции изменён локально.");
}],
["⧉", "Дублировать", () => {
items.splice(index + 1, 0, prepareCollectionItemForInsert(items, item));
const nextItem = prepareCollectionItemForInsert(items, item);
items.splice(index + 1, 0, nextItem);
if (details.open) openKeys.add(collectionItemKey(nextItem, index + 1));
rerender("Элемент коллекции продублирован локально.");
}],
["⌫", "Удалить", async () => {
const confirmed = await requestDeleteConfirmation();
if (!confirmed) return;
openKeys.delete(itemKey);
items.splice(index, 1);
rerender("Элемент коллекции удалён локально.");
}],
@ -1947,6 +2141,11 @@ function renderContentFields(block) {
continue;
}
if (editableField.kind === "link-mode") {
renderLinkModeField({ block, field: editableField, grid });
continue;
}
if (editableField.kind === "boolean") {
renderBooleanField({ block, editableField, grid });
continue;

View File

@ -970,6 +970,7 @@
"renderAs": "collection",
"group": "FAQ",
"itemTemplate": {
"enabled": true,
"question": "Новый вопрос",
"answerHtml": "<p>Новый ответ.</p>"
},
@ -2420,23 +2421,19 @@
"renderAs": "text"
},
{
"path": "href",
"label": "Адрес",
"kind": "url",
"renderAs": "attr"
},
{
"path": "target",
"label": "Target",
"kind": "text",
"path": "linkMode",
"label": "Источник ссылки",
"kind": "link-mode",
"renderAs": "attr"
}
],
"itemTemplate": {
"enabled": true,
"label": "Новая ссылка",
"href": "index.html#",
"target": "",
"enabled": true
"href": "",
"target": "index.html#",
"linkMode": "target",
"linkTarget": ""
}
},
{
@ -2460,23 +2457,19 @@
"renderAs": "text"
},
{
"path": "href",
"label": "Адрес",
"kind": "url",
"renderAs": "attr"
},
{
"path": "target",
"label": "Target",
"kind": "text",
"path": "linkMode",
"label": "Источник ссылки",
"kind": "link-mode",
"renderAs": "attr"
}
],
"itemTemplate": {
"enabled": true,
"label": "Новая ссылка",
"href": "https://",
"target": "_blank",
"enabled": true
"target": "",
"linkMode": "href",
"linkTarget": "_blank"
}
},
{

View File

@ -15,45 +15,67 @@
"links": [
{
"label": "О продукте",
"href": "index.html#features",
"target": ""
"href": "",
"target": "index.html#features",
"enabled": true,
"linkMode": "target",
"linkTarget": ""
},
{
"label": "Запрос инвайта",
"href": "index.html#hero",
"target": ""
"href": "",
"target": "index.html#hero",
"enabled": true,
"linkMode": "target",
"linkTarget": ""
},
{
"label": "Документация",
"href": "https://docs.ssscript.app/",
"target": "_blank"
"label": "Возможности",
"href": "https://nodedc.dctouch.ru/",
"target": "",
"enabled": true,
"linkMode": "href",
"linkTarget": "_blank"
},
{
"label": "Тарифы",
"href": "index.html#pricing-wrap",
"target": ""
"href": "",
"target": "index.html#pricing-wrap",
"enabled": false,
"linkMode": "target",
"linkTarget": ""
}
],
"logoSrc": "./assets/uploads/images/static-elements/dclogo_darck-mquq1l13-transparent.png"
},
"notifications": [
{
"eyebrowHtml": "<span class=\"darker-70\">ДАЛЕЕ</span> — ИЮЛЬ 2026",
"title": "Открытая бета",
"body": "Открываем доступ всем. Будем собирать и отлаживать вместе.",
"eyebrowClass": "red-txt gray"
"eyebrowHtml": "<span class=\"darker-70\">РАНЕЕ</span> — МАЙ 2026",
"title": "Engineering Core выделен в отдельный модуль",
"body": "Единый контур инженерных моделей, версий, согласований и рабочих материалов.",
"eyebrowClass": "red-txt gray",
"enabled": true
},
{
"eyebrowHtml": "<span class=\"darker-70\">ДАЛЕЕ</span> —<span class=\"darker-70\"> </span>ИЮЛЬ 2026",
"title": "Альфа для Founder-доступа",
"body": "На второй фазе тестирования бета открыта для всех подписчиков Founder-тарифа.",
"eyebrowClass": "red-txt gray"
"eyebrowHtml": "<span class=\"darker-70\">РАНЕЕ</span> —<span class=\"darker-70\"> </span>ИЮНЬ 2026",
"title": "Knowledge Graph добавлен в состав экосистемы",
"body": "Связи между задачами, агентами, кодовой базой, документами и инженерными сущностями собраны в общем графе.",
"eyebrowClass": "red-txt gray",
"enabled": true
},
{
"eyebrowHtml": "СЕЙЧАС",
"title": "Инвайты в закрытую бету уже отправлены.",
"body": "Сейчас тестируем базовую функциональность и ИИ-интеграцию. ",
"eyebrowClass": "red-txt"
"title": "Открыт alpha-доступ к Procurement Intelligence",
"body": "Агентный контур для закупок, сопоставления условий и контроля поставок.",
"eyebrowClass": "red-txt",
"enabled": true
},
{
"eyebrowHtml": "<span class=\"darker-70\">ДАЛЕЕ</span> —<span class=\"darker-70\"> </span>СЕНТЯБРЬ 2026",
"eyebrowClass": "red-txt",
"title": "Field Ops включён в план релиза",
"body": "Мобильный операционный модуль для выездных команд, инспекций, фотофиксации и полевых отчётов.",
"enabled": true
}
],
"dock": {
@ -124,9 +146,12 @@
"renderAs": "collection",
"group": "Шапка: ссылки",
"itemTemplate": {
"enabled": true,
"label": "Новая ссылка",
"href": "index.html#",
"target": ""
"href": "",
"target": "index.html#",
"linkMode": "target",
"linkTarget": ""
},
"itemFields": [
{
@ -137,16 +162,9 @@
"group": "Шапка: ссылки"
},
{
"path": "href",
"label": "Адрес",
"kind": "url",
"renderAs": "attr",
"group": "Шапка: ссылки"
},
{
"path": "target",
"label": "Target",
"kind": "text",
"path": "linkMode",
"label": "Источник ссылки",
"kind": "link-mode",
"renderAs": "attr",
"group": "Шапка: ссылки"
}
@ -159,6 +177,7 @@
"renderAs": "collection",
"group": "Уведомления",
"itemTemplate": {
"enabled": true,
"eyebrowHtml": "СЕЙЧАС",
"eyebrowClass": "red-txt gray",
"title": "Новое уведомление",
@ -1761,65 +1780,79 @@
"main": "и ответы"
},
"introHtml": "<span class=\"darker-70\">Подробный обзор</span> возможностей, логики работы и внутреннего устройства.",
"docsHref": "https://docs.ssscript.app/",
"docsLabel": "Полная документация",
"docsHref": "https://nodedc.dctouch.ru/",
"docsLabel": "Возможности ",
"windowTitle": "FAQ",
"faq": [
{
"question": "Что входит в NODE.DC?",
"answerHtml": "<p>NODE.DC включает Hub для входа и управления доступами, Engine для разработки workflow и прикладной логики, Ops для операционной работы, сквозного ассистента и подключаемые модули: 1C Assistant, Tender, Digital Twin, Model Viewer / Engineering Core и отраслевые приложения.</p>"
"answerHtml": "<p>NODE.DC включает Hub для входа и управления доступами, Engine для разработки workflow и прикладной логики, Ops для операционной работы, сквозного ассистента и подключаемые модули: 1C Assistant, Tender, Digital Twin, Model Viewer / Engineering Core и отраслевые приложения.</p>",
"enabled": true
},
{
"question": "Зачем нужен Hub?",
"answerHtml": "<p>Hub выполняет роль лаунчера и административной точки входа. В нем отображается каталог доступных приложений, рабочие пространства, роли, группы пользователей и права доступа к модулям платформы.</p>"
"answerHtml": "<p>Hub выполняет роль лаунчера и административной точки входа. В нем отображается каталог доступных приложений, рабочие пространства, роли, группы пользователей и права доступа к модулям платформы.</p>",
"enabled": true
},
{
"question": "Как связаны Hub, Engine и Ops?",
"answerHtml": "<p>Hub отвечает за вход, роли и доступ к приложениям. Engine используется для сборки workflow, прикладной логики, интеграций и интерфейсов. Ops принимает задачи, отчеты, статусы, результаты работы модулей и действия агентов в операционный контур.</p>"
"answerHtml": "<p>Hub отвечает за вход, роли и доступ к приложениям. Engine используется для сборки workflow, прикладной логики, интеграций и интерфейсов. Ops принимает задачи, отчеты, статусы, результаты работы модулей и действия агентов в операционный контур.</p>",
"enabled": true
},
{
"question": "Что создается в Engine?",
"answerHtml": "<p>В Engine создаются workflow, интеграционные сценарии, обработчики данных, прикладная логика, интерфейсные связки и сервисные контуры. Сборка может выполняться вручную в нодовом редакторе или через ассистента по текстовому заданию.</p>"
"answerHtml": "<p>В Engine создаются workflow, интеграционные сценарии, обработчики данных, прикладная логика, интерфейсные связки и сервисные контуры. Сборка может выполняться вручную в нодовом редакторе или через ассистента по текстовому заданию.</p>",
"enabled": true
},
{
"question": "Что фиксируется в Ops?",
"answerHtml": "<p>В Ops фиксируются задачи, статусы, карточки, отчеты, согласования, рабочие материалы, результаты агентов и данные из подключенных модулей. Слой предназначен для контроля процессов и операционной работы.</p>"
"answerHtml": "<p>В Ops фиксируются задачи, статусы, карточки, отчеты, согласования, рабочие материалы, результаты агентов и данные из подключенных модулей. Слой предназначен для контроля процессов и операционной работы.</p>",
"enabled": true
},
{
"question": "Как работает сквозной ассистент?",
"answerHtml": "<p>Ассистент работает как единый интерфейс к приложениям и данным платформы. В рамках прав доступа ему доступны задачи, процессы, кодовая база, проектные материалы, отчеты, инженерные модели и результаты работы агентов. Ассистент может отвечать на вопросы, декомпозировать задачи, готовить материалы и инициировать действия в связанных слоях.</p>"
"answerHtml": "<p>Ассистент работает как единый интерфейс к приложениям и данным платформы. В рамках прав доступа ему доступны задачи, процессы, кодовая база, проектные материалы, отчеты, инженерные модели и результаты работы агентов. Ассистент может отвечать на вопросы, декомпозировать задачи, готовить материалы и инициировать действия в связанных слоях.</p>",
"enabled": true
},
{
"question": "Как работают прикладные модули?",
"answerHtml": "<p>Прикладные модули подключаются к ролям, задачам, данным, рабочим пространствам и ассистенту. Модуль закрывает не отдельный экран, а рабочий контур: входные данные, обработку, действия, отчеты, согласования и передачу результата в Ops.</p>"
"answerHtml": "<p>Прикладные модули подключаются к ролям, задачам, данным, рабочим пространствам и ассистенту. Модуль закрывает не отдельный экран, а рабочий контур: входные данные, обработку, действия, отчеты, согласования и передачу результата в Ops.</p>",
"enabled": true
},
{
"question": "Какие модули можно подключать?",
"answerHtml": "<p>Базовый набор включает Hub, Engine и Ops. Дополнительно подключаются 1C Assistant, Tender, Digital Twin, Engineering Core, интеграционные сценарии, API-модули и специализированные приложения для подразделений.</p>"
"answerHtml": "<p>Базовый набор включает Hub, Engine и Ops. Дополнительно подключаются 1C Assistant, Tender, Digital Twin, Engineering Core, интеграционные сценарии, API-модули и специализированные приложения для подразделений.</p>",
"enabled": true
},
{
"question": "Поддерживаются ли инженерные форматы?",
"answerHtml": "<p>Для инженерных и производственных сценариев предусмотрена работа с цифровыми моделями и техническими материалами. Поддерживаются BIM, STEP, STL, 3DGS / Gaussian Splatting, LAS/LAZ-облака точек и другие форматы, связанные с задачами, версиями и проектным контекстом.</p>"
"answerHtml": "<p>Для инженерных и производственных сценариев предусмотрена работа с цифровыми моделями и техническими материалами. Поддерживаются BIM, STEP, STL, 3DGS / Gaussian Splatting, LAS/LAZ-облака точек и другие форматы, связанные с задачами, версиями и проектным контекстом.</p>",
"enabled": true
},
{
"question": "Как настраиваются роли и права доступа?",
"answerHtml": "<p>Права доступа задаются через Hub. Администратор может назначать роли, формировать группы, разделять контуры доступа по подразделениям и выдавать индивидуальные права на приложения, функции и рабочие области.</p>"
"answerHtml": "<p>Права доступа задаются через Hub. Администратор может назначать роли, формировать группы, разделять контуры доступа по подразделениям и выдавать индивидуальные права на приложения, функции и рабочие области.</p>",
"enabled": true
},
{
"question": "Можно ли подключать корпоративные системы?",
"answerHtml": "<p>NODE.DC рассчитан на подключение внешних сервисов, API, внутренних приложений, 1C, баз данных, файловых хранилищ и корпоративных систем. Интеграционные сценарии собираются в Engine и передают результаты в Ops, прикладные модули или пользовательские интерфейсы.</p>"
"answerHtml": "<p>NODE.DC рассчитан на подключение внешних сервисов, API, внутренних приложений, 1C, баз данных, файловых хранилищ и корпоративных систем. Интеграционные сценарии собираются в Engine и передают результаты в Ops, прикладные модули или пользовательские интерфейсы.</p>",
"enabled": true
},
{
"question": "Где размещается NODE.DC?",
"answerHtml": "<p>Доступны облачные, выделенные, гибридные и enterprise-варианты размещения. Итоговая конфигурация зависит от требований к безопасности, составу модулей, интеграциям и политике хранения данных.</p>"
"answerHtml": "<p>Доступны облачные, выделенные, гибридные и enterprise-варианты размещения. Итоговая конфигурация зависит от требований к безопасности, составу модулей, интеграциям и политике хранения данных.</p>",
"enabled": true
},
{
"question": "Как запускается пилотный контур?",
"answerHtml": "<p>Пилотный контур формируется по составу модулей, ролей, интеграций и рабочих сценариев. Обычно подключается Hub, настраиваются базовые роли, выбирается один или несколько процессов, после чего собираются workflow, рабочие представления и отчеты.</p>"
"answerHtml": "<p>Пилотный контур формируется по составу модулей, ролей, интеграций и рабочих сценариев. Обычно подключается Hub, настраиваются базовые роли, выбирается один или несколько процессов, после чего собираются workflow, рабочие представления и отчеты.</p>",
"enabled": true
},
{
"question": "Чем NODE.DC отличается от набора отдельных приложений?",
"answerHtml": "<p>NODE.DC связывает приложения в единую платформенную архитектуру: общий доступ, роли, сквозной ассистент, связанные задачи, данные, workflow и операционный слой. Это снижает разрыв между разработкой, управлением и исполнением процессов.</p>"
"answerHtml": "<p>NODE.DC связывает приложения в единую платформенную архитектуру: общий доступ, роли, сквозной ассистент, связанные задачи, данные, workflow и операционный слой. Это снижает разрыв между разработкой, управлением и исполнением процессов.</p>",
"enabled": true
}
],
"sender": {
@ -1827,7 +1860,7 @@
"avatarAlt": "Аватар NODE.DC",
"name": "NODE.DC",
"replyLabel": "Ответить",
"sentLabel": "Отправлено с iPhone"
"sentLabel": "Отправлено с NODE.DC MANUAL CORE"
},
"scroll": {
"speed": 0.7
@ -1936,6 +1969,7 @@
"renderAs": "collection",
"group": "FAQ",
"itemTemplate": {
"enabled": true,
"question": "Новый вопрос",
"answerHtml": "<p>Новый ответ.</p>"
},
@ -2594,7 +2628,7 @@
"template": "screenshot-cards.html",
"adminLabel": "Карточки скриншотов",
"anchor": null,
"enabled": true,
"enabled": false,
"content": {
"galleryItems": [
{
@ -3155,7 +3189,7 @@
"template": "demo-video-folder.html",
"adminLabel": "Иконка демо-видео в доке",
"anchor": null,
"enabled": true,
"enabled": false,
"content": {
"target": "demo-video",
"to": "features",
@ -3215,38 +3249,48 @@
"appIconAlt": "Иконка NODE.DC",
"wordmarkSrc": "./assets/webflow/images/69ad96a28b06b7552a339137_logtype.svg?v=node-dc",
"wordmarkAlt": "Логотип NODE.DC",
"copyrightHtml": "© 2026<a href=\"https://federic.ooo/\" target=\"_blank\"></a> —  <a href=\"https://federic.ooo/\" target=\"_blank\">NODE.DC</a>",
"copyrightHtml": "© 2026<a href=\"https://www.dctouch.ru/\" target=\"_blank\"></a> —  <a href=\"https://www.dctouch.ru//\" target=\"_blank\">NODE.DC</a>",
"navTitle": "Навигация",
"navLinks": [
{
"label": "Документация",
"href": "https://docs.ssscript.app/",
"target": "_blank",
"enabled": true
"target": "",
"enabled": false,
"linkMode": "href",
"linkTarget": "_blank"
},
{
"label": "Лист ожидания",
"href": "index.html#waitlist",
"target": "",
"enabled": true
"href": "",
"target": "index.html#waitlist",
"enabled": false,
"linkMode": "target",
"linkTarget": ""
},
{
"label": "Тарифы",
"href": "index.html#",
"target": "",
"enabled": true
"href": "",
"target": "index.html#",
"enabled": false,
"linkMode": "target",
"linkTarget": ""
},
{
"label": "Возможности",
"href": "index.html#features",
"target": "",
"enabled": true
"href": "https://nodedc.dctouch.ru/",
"target": "index.html#features",
"enabled": true,
"linkMode": "href",
"linkTarget": ""
},
{
"label": "Демо",
"href": "index.html#features",
"target": "",
"enabled": true
"href": "",
"target": "index.html#features",
"enabled": true,
"linkMode": "target",
"linkTarget": ""
}
],
"contactTitle": "Контакты",
@ -3254,30 +3298,38 @@
{
"label": "DCTOUCH",
"href": "https://www.dctouch.ru/",
"target": "_blank",
"enabled": true
"target": "",
"enabled": true,
"linkMode": "href",
"linkTarget": "_blank"
},
{
"label": "Telegram",
"href": "https://t.me/dcconstructions",
"target": "_blank",
"enabled": true
"target": "",
"enabled": true,
"linkMode": "href",
"linkTarget": "_blank"
},
{
"label": "GitHub",
"href": "https://www.federic.ooo/s/github",
"target": "_blank",
"enabled": false
"target": "",
"enabled": false,
"linkMode": "href",
"linkTarget": "_blank"
},
{
"label": "Портфолио",
"href": "https://federic.ooo/",
"target": "_blank",
"enabled": false
"target": "",
"enabled": false,
"linkMode": "href",
"linkTarget": "_blank"
}
],
"policyHtml": "<a href=\"https://docs.google.com/document/d/1vME2w8ZB88HoH-JyvkkiL7exrcMfWhkERfFRlnIeZFA/edit?usp=sharing\" target=\"_blank\">Политика конфиденциальности</a> &amp; <a href=\"https://docs.google.com/document/d/1vME2w8ZB88HoH-JyvkkiL7exrcMfWhkERfFRlnIeZFA/edit?usp=sharing\" target=\"_blank\">Условия использования</a>",
"policyEnabled": true
"policyEnabled": false
}
},
"editableFields": [
@ -3337,23 +3389,19 @@
"renderAs": "text"
},
{
"path": "href",
"label": "Адрес",
"kind": "url",
"renderAs": "attr"
},
{
"path": "target",
"label": "Target",
"kind": "text",
"path": "linkMode",
"label": "Источник ссылки",
"kind": "link-mode",
"renderAs": "attr"
}
],
"itemTemplate": {
"enabled": true,
"label": "Новая ссылка",
"href": "index.html#",
"target": "",
"enabled": true
"href": "",
"target": "index.html#",
"linkMode": "target",
"linkTarget": ""
}
},
{
@ -3377,23 +3425,19 @@
"renderAs": "text"
},
{
"path": "href",
"label": "Адрес",
"kind": "url",
"renderAs": "attr"
},
{
"path": "target",
"label": "Target",
"kind": "text",
"path": "linkMode",
"label": "Источник ссылки",
"kind": "link-mode",
"renderAs": "attr"
}
],
"itemTemplate": {
"enabled": true,
"label": "Новая ссылка",
"href": "https://",
"target": "_blank",
"enabled": true
"target": "",
"linkMode": "href",
"linkTarget": "_blank"
}
},
{

View File

@ -1 +1 @@
<section class="s h-screen py mobile-space"><div class="c ve"><hgroup data-module="hg"><h2 class="main-h h1"><span class="darker-40">{{text:content.heading.muted}}</span><span class="darker-70">{{text:content.heading.middle}}</span><span>{{text:content.heading.main}}</span></h2><div class="h-txt">{{html:content.introHtml}}</div></hgroup><a href="{{attr:content.docsHref}}" target="_blank" class="ft-link w-inline-block"><button data-module="btn" class="form-btn static"><div data-module="alpha" class="btn-bg gray"><div class="clip-12"><div>{{text:content.docsLabel}}</div></div></div></button></a></div><div class="app-w mail"><div id="faq-item" data-module="app" class="app-item mail"><div data-draggable-area="" class="app-head preview"><div class="app-head-trigs"><button data-close="" aria-label="закрыть" class="circle opaque"><div class="x-icon"><div class="line full x-icon rot"></div><div class="line full x-icon"></div></div></button><div class="circle opaque"></div></div><div class="app-name-w"><div class="app-name-txt preview">{{text:content.windowTitle}}</div></div></div><div class="mail-app"><div><div class="mail-wrapper w-dyn-list"><div data-css="fade-tb" data-module="mail-list" data-scroll-speed="{{attr:content.scroll.speed}}" role="list" class="mail-list w-dyn-items">{{#each content.faq}}<div role="listitem" class="mail-slide w-dyn-item"><div class="mail-item"><div data-unread="" class="unread-email"><div class="dot relative email"></div></div><div class="mail-head"><div class="mail-p normal darker">{{text:content.sender.name}}</div><div class="mail-p">{{text:question}}</div></div></div></div>{{/each}}</div></div></div><div class="mail-rel-wrap"><div class="collection-list-wrapper w-dyn-list"><div data-module="mail-view" role="list" class="collection-list w-dyn-items">{{#each content.faq}}<div role="listitem" class="mail-view w-dyn-item"><div class="mail-size"><div class="mail-head-w"><div class="mail-img-w" role="img" aria-label="{{attr:content.sender.avatarAlt}}" style="background-image:url('{{attr:content.sender.avatarSrc}}')"></div><div class="mail-head"><div class="mail-p normal">{{text:content.sender.name}}</div><div class="div-block-7"><div class="mail-p smaller"><span class="darker">{{text:content.sender.replyLabel}}</span></div><div class="mail-p">{{text:question}}</div></div></div></div><div class="line ho darker"></div><div class="mail-body"><div class="mail-rt w-richtext">{{html:answerHtml}}</div></div><div class="darker sm">{{text:content.sender.sentLabel}}</div></div></div>{{/each}}</div></div></div></div></div></div></section>
<section class="s h-screen py mobile-space"><div class="c ve"><hgroup data-module="hg"><h2 class="main-h h1"><span class="darker-40">{{text:content.heading.muted}}</span><span class="darker-70">{{text:content.heading.middle}}</span><span>{{text:content.heading.main}}</span></h2><div class="h-txt">{{html:content.introHtml}}</div></hgroup><a href="{{attr:content.docsHref}}" target="_blank" class="ft-link w-inline-block"><button data-module="btn" class="form-btn static"><div data-module="alpha" class="btn-bg gray"><div class="clip-12"><div>{{text:content.docsLabel}}</div></div></div></button></a></div><div class="app-w mail"><div id="faq-item" data-module="app" class="app-item mail"><div data-draggable-area="" class="app-head preview"><div class="app-head-trigs"><button data-close="" aria-label="закрыть" class="circle opaque"><div class="x-icon"><div class="line full x-icon rot"></div><div class="line full x-icon"></div></div></button><div class="circle opaque"></div></div><div class="app-name-w"><div class="app-name-txt preview">{{text:content.windowTitle}}</div></div></div><div class="mail-app"><div><div class="mail-wrapper w-dyn-list"><div data-css="fade-tb" data-module="mail-list" data-scroll-speed="{{attr:content.scroll.speed}}" role="list" class="mail-list w-dyn-items">{{#each content.faq}}{{#if enabled}}<div role="listitem" class="mail-slide w-dyn-item"><div class="mail-item"><div data-unread="" class="unread-email"><div class="dot relative email"></div></div><div class="mail-head"><div class="mail-p normal darker">{{text:content.sender.name}}</div><div class="mail-p">{{text:question}}</div></div></div></div>{{/if}}{{/each}}</div></div></div><div class="mail-rel-wrap"><div class="collection-list-wrapper w-dyn-list"><div data-module="mail-view" role="list" class="collection-list w-dyn-items">{{#each content.faq}}{{#if enabled}}<div role="listitem" class="mail-view w-dyn-item"><div class="mail-size"><div class="mail-head-w"><div class="mail-img-w" role="img" aria-label="{{attr:content.sender.avatarAlt}}" style="background-image:url('{{attr:content.sender.avatarSrc}}')"></div><div class="mail-head"><div class="mail-p normal">{{text:content.sender.name}}</div><div class="div-block-7"><div class="mail-p smaller"><span class="darker">{{text:content.sender.replyLabel}}</span></div><div class="mail-p">{{text:question}}</div></div></div></div><div class="line ho darker"></div><div class="mail-body"><div class="mail-rt w-richtext">{{html:answerHtml}}</div></div><div class="darker sm">{{text:content.sender.sentLabel}}</div></div></div>{{/if}}{{/each}}</div></div></div></div></div></div></section>

View File

@ -1 +1 @@
<footer class="s footer"><div class="c footer-col"><div class="div-block-9"><div class="div-block-2"><img data-module="mouse-follow" loading="lazy" alt="{{attr:content.footer.appIconAlt}}" src="{{attr:content.footer.appIconSrc}}" class="logo app footer"/><img loading="lazy" src="{{attr:content.footer.wordmarkSrc}}" alt="{{attr:content.footer.wordmarkAlt}}" class="logo-size"/></div><div class="footer-notice mt">{{html:content.footer.copyrightHtml}}</div></div><div class="footer-cols"><div class="div-block-3"><h2 class="footer-list-he">{{text:content.footer.navTitle}}</h2><ul data-group="hero" data-module="list" role="list" class="w-list-unstyled">{{#each content.footer.navLinks}}{{#if enabled}}<li><a data-module="link" href="{{attr:href}}" target="{{attr:target}}" class="ft-link w-inline-block"><div class="clip-12"><div>{{text:label}}</div></div></a></li>{{/if}}{{/each}}</ul></div><div class="div-block-3"><h2 data-module="text-wave" class="footer-list-he">{{text:content.footer.contactTitle}}</h2><ul data-group="hero" data-module="list" role="list" class="w-list-unstyled">{{#each content.footer.contactLinks}}{{#if enabled}}<li><a data-module="link" href="{{attr:href}}" target="{{attr:target}}" class="ft-link w-inline-block"><div class="clip-12"><div>{{text:label}}</div></div></a></li>{{/if}}{{/each}}</ul></div></div></div>{{#if content.footer.policyEnabled}}<div class="c footer-col"><div class="footer-notice mt">{{html:content.footer.policyHtml}}</div></div>{{/if}}</footer>
<footer class="s footer"><div class="c footer-col"><div class="div-block-9"><div class="div-block-2"><img data-module="mouse-follow" loading="lazy" alt="{{attr:content.footer.appIconAlt}}" src="{{attr:content.footer.appIconSrc}}" class="logo app footer"/><img loading="lazy" src="{{attr:content.footer.wordmarkSrc}}" alt="{{attr:content.footer.wordmarkAlt}}" class="logo-size"/></div><div class="footer-notice mt">{{html:content.footer.copyrightHtml}}</div></div><div class="footer-cols"><div class="div-block-3"><h2 class="footer-list-he">{{text:content.footer.navTitle}}</h2><ul data-group="hero" data-module="list" role="list" class="w-list-unstyled">{{#each content.footer.navLinks}}{{#if enabled}}<li><a data-module="link" href="{{attr:resolvedHref}}" target="{{attr:resolvedTarget}}" class="ft-link w-inline-block"><div class="clip-12"><div>{{text:label}}</div></div></a></li>{{/if}}{{/each}}</ul></div><div class="div-block-3"><h2 data-module="text-wave" class="footer-list-he">{{text:content.footer.contactTitle}}</h2><ul data-group="hero" data-module="list" role="list" class="w-list-unstyled">{{#each content.footer.contactLinks}}{{#if enabled}}<li><a data-module="link" href="{{attr:resolvedHref}}" target="{{attr:resolvedTarget}}" class="ft-link w-inline-block"><div class="clip-12"><div>{{text:label}}</div></div></a></li>{{/if}}{{/each}}</ul></div></div></div>{{#if content.footer.policyEnabled}}<div class="c footer-col"><div class="footer-notice mt">{{html:content.footer.policyHtml}}</div></div>{{/if}}</footer>

File diff suppressed because one or more lines are too long

View File

@ -68,6 +68,55 @@ function applySharedDemoRequest(model) {
}
}
const LINK_WINDOW_TARGETS = new Set(["_blank", "_self", "_parent", "_top"]);
function isWindowTarget(value) {
return LINK_WINDOW_TARGETS.has(String(value || "").trim());
}
function isExternalHref(value) {
return /^(https?:)?\/\//i.test(String(value || "")) || /^(mailto:|tel:)/i.test(String(value || ""));
}
function normalizeLinkForRender(item) {
if (!item || typeof item !== "object") return;
let href = item.href ?? "";
let target = item.target ?? "";
let linkTarget = item.linkTarget ?? "";
if (isWindowTarget(target)) {
linkTarget ||= target;
target = "";
}
const mode = item.linkMode === "href" || item.linkMode === "target" ? item.linkMode : isExternalHref(href) ? "href" : "target";
if (mode === "target" && !target && href && !isExternalHref(href)) {
target = href;
href = "";
}
const primaryHref = mode === "href" ? href : target;
const fallbackHref = mode === "href" ? target : href;
item.resolvedHref = primaryHref || fallbackHref || "#";
item.resolvedTarget = mode === "href" ? linkTarget : "";
}
function applyRenderableLinks(model) {
const staticLinks = model.staticElements?.content?.navigation?.links;
if (Array.isArray(staticLinks)) {
staticLinks.forEach(normalizeLinkForRender);
}
for (const block of allBlocks(model)) {
const footer = block.content?.footer;
if (!footer) continue;
if (Array.isArray(footer.navLinks)) footer.navLinks.forEach(normalizeLinkForRender);
if (Array.isArray(footer.contactLinks)) footer.contactLinks.forEach(normalizeLinkForRender);
}
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
@ -232,6 +281,7 @@ async function renderSections() {
}
applySharedDemoRequest(page);
applyRenderableLinks(page);
let output = renderTemplate(shell, { id: page.slug, ...page });