feat(control-station): configure page-specific shell

This commit is contained in:
DCCONSTRUCTIONS
2026-07-26 17:06:04 +03:00
parent dc55ff16c9
commit 360ec39ff3
9 changed files with 619 additions and 259 deletions
@@ -6,6 +6,7 @@ import {
Select,
SettingsCard,
Switch,
TextAreaField,
TextField,
WindowFooterActions,
} from "@nodedc/ui-react";
@@ -14,11 +15,16 @@ import {
cloneEnvironmentSettings,
type EnvironmentBackground,
type EnvironmentMediaKind,
type EnvironmentPage,
type EnvironmentSettings,
type EnvironmentSurfaceId,
type UploadedEnvironmentMedia,
} from "../core/environment/environmentSettings";
import { roots, type RootId } from "../productModel";
import {
roots,
workspaces,
workspacesForRoot,
} from "../productModel";
interface EnvironmentSettingsWindowProps {
open: boolean;
@@ -33,52 +39,39 @@ interface EnvironmentSettingsWindowProps {
) => Promise<UploadedEnvironmentMedia>;
}
const headerLabelFields: readonly { id: RootId; label: string }[] = [
{ id: "center", label: "Центр" },
{ id: "fleet", label: "Парк" },
{ id: "observation", label: "Наблюдение" },
{ id: "missions", label: "Миссии" },
{ id: "data", label: "Данные" },
{ id: "system", label: "Система" },
{ id: "polygon", label: "Тестировочный контур" },
];
const backgroundSurfaceOptions: Array<{
value: EnvironmentSurfaceId;
label: string;
description: string;
}> = [
{
value: "home",
label: "Mission Core",
description: "Главная страница продукта",
},
...roots.map((root) => ({
value: root.id,
label: root.title,
description: `Стартовая страница раздела «${root.label}»`,
})),
];
function inferMediaKind(url: string): EnvironmentMediaKind {
return /\.(mp4|webm|mov|m4v)(?:[?#].*)?$/i.test(url) ? "video" : "image";
}
function patchPage(
draft: EnvironmentSettings,
surfaceId: EnvironmentSurfaceId,
patch: Partial<EnvironmentPage>,
): EnvironmentSettings {
return {
...draft,
pages: {
...draft.pages,
[surfaceId]: {
...draft.pages[surfaceId],
...patch,
},
},
};
}
function patchBackground(
draft: EnvironmentSettings,
surfaceId: EnvironmentSurfaceId,
patch: Partial<EnvironmentBackground>,
): EnvironmentSettings {
return {
...draft,
backgrounds: {
...draft.backgrounds,
[surfaceId]: {
...draft.backgrounds[surfaceId],
...patch,
},
const page = draft.pages[surfaceId];
return patchPage(draft, surfaceId, {
background: {
...page.background,
...patch,
},
};
});
}
export function EnvironmentSettingsWindow({
@@ -91,7 +84,7 @@ export function EnvironmentSettingsWindow({
onUpload,
}: EnvironmentSettingsWindowProps) {
const [draft, setDraft] = useState(() => cloneEnvironmentSettings(settings));
const [surfaceId, setSurfaceId] = useState<EnvironmentSurfaceId>("home");
const [surfaceId, setSurfaceId] = useState<EnvironmentSurfaceId>("fleet");
const [uploading, setUploading] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
@@ -101,20 +94,48 @@ export function EnvironmentSettingsWindow({
setLocalError(null);
}, [open, settings]);
const selectedBackground = draft.backgrounds[surfaceId];
const selectedPage = draft.pages[surfaceId];
const selectedBackground = selectedPage.background;
const pageOptions = useMemo(() => [
{
value: "home" as const,
label: draft.pages.home.headerLabel,
description: "Главная страница продукта",
},
...roots.map((root) => ({
value: root.id,
label: draft.pages[root.id].headerLabel,
description: `Стартовая страница раздела «${draft.pages[root.id].title}»`,
})),
], [draft.pages]);
const previewKind = selectedBackground.mediaKind
?? (selectedBackground.url ? inferMediaKind(selectedBackground.url) : null);
const quickActionWorkspaces = useMemo(
() => surfaceId === "home"
? workspaces.filter((workspace) => !workspace.internalOnly)
: workspacesForRoot(surfaceId),
[surfaceId],
);
const quickActionOptions = useMemo(() => [
{
value: "none",
label: "Не показывать",
description: "Кнопка скрыта на стартовом экране",
},
...quickActionWorkspaces.map((workspace) => ({
value: workspace.id,
label: workspace.label,
description: workspace.description,
})),
], [quickActionWorkspaces]);
const dirty = useMemo(
() => JSON.stringify(draft) !== JSON.stringify(settings),
[draft, settings],
);
const busy = state === "saving" || uploading;
const updateHeaderLabel = (rootId: RootId, value: string) => {
setDraft((current) => ({
...current,
headerLabels: { ...current.headerLabels, [rootId]: value },
}));
const updatePage = (patch: Partial<EnvironmentPage>) => {
setDraft((current) => patchPage(current, surfaceId, patch));
};
const uploadFile = async (file?: File) => {
@@ -140,19 +161,36 @@ export function EnvironmentSettingsWindow({
};
const save = async () => {
const blank = headerLabelFields.find(({ id }) => !draft.headerLabels[id].trim());
if (blank) {
setLocalError(`Название «${blank.label}» не может быть пустым.`);
const invalid = Object.entries(draft.pages).find(([, page]) => (
!page.headerLabel.trim()
|| !page.eyebrow.trim()
|| !page.title.trim()
|| !page.description.trim()
));
if (invalid) {
setLocalError("Название, надзаголовок, заголовок и описание не могут быть пустыми.");
return;
}
if (
selectedPage.primaryWorkspaceId
&& selectedPage.primaryWorkspaceId === selectedPage.secondaryWorkspaceId
) {
setLocalError("Быстрые кнопки должны вести на разные рабочие поверхности.");
return;
}
setLocalError(null);
try {
await onSave({
...draft,
headerLabels: Object.fromEntries(headerLabelFields.map(({ id }) => [
id,
draft.headerLabels[id].trim(),
])) as Record<RootId, string>,
pages: Object.fromEntries(
Object.entries(draft.pages).map(([id, page]) => [id, {
...page,
headerLabel: page.headerLabel.trim(),
eyebrow: page.eyebrow.trim(),
title: page.title.trim(),
description: page.description.trim(),
}]),
) as EnvironmentSettings["pages"],
});
onClose();
} catch (reason) {
@@ -207,31 +245,13 @@ export function EnvironmentSettingsWindow({
>
<div className="environment-settings">
<SettingsCard
eyebrow="ШАПКА"
title="Названия разделов"
description="Подписи применяются к верхней навигации. Продуктовые идентификаторы и маршруты не меняются."
>
<div className="environment-settings__labels">
{headerLabelFields.map((field) => (
<TextField
key={field.id}
label={field.label}
value={draft.headerLabels[field.id]}
maxLength={40}
onChange={(event) => updateHeaderLabel(field.id, event.currentTarget.value)}
/>
))}
</div>
</SettingsCard>
<SettingsCard
eyebrow="ПОДЛОЖКИ"
title="Фото или видео стартовой страницы"
description="Медиа заполняет выбранную стартовую страницу с автокадрированием. Рабочие поверхности и viewer не затрагиваются."
eyebrow="ОКРУЖЕНИЕ"
title="Основные элементы управления"
description="Выберите страницу и настройте её название в шапке, содержание стартового экрана, подложку и быстрые переходы."
actions={(
<Switch
checked={selectedBackground.enabled}
label="Показывать"
label="Показывать фон"
onChange={(enabled) => {
if (enabled && !selectedBackground.url) {
setLocalError("Сначала загрузите файл или укажите URL.");
@@ -244,13 +264,13 @@ export function EnvironmentSettingsWindow({
/>
)}
>
<div className="environment-settings__media">
<div className="environment-settings__editor">
<div className="environment-settings__surface">
<span>Экран</span>
<span>Страница</span>
<Select
label="Выбрать стартовую страницу"
label="Выбрать страницу окружения"
value={surfaceId}
options={backgroundSurfaceOptions}
options={pageOptions}
variant="split"
menuWidth="anchor"
onChange={(value) => {
@@ -259,6 +279,72 @@ export function EnvironmentSettingsWindow({
}}
/>
</div>
<div className="environment-settings__copy">
<TextField
label={surfaceId === "home" ? "Название продукта" : "Название в шапке"}
value={selectedPage.headerLabel}
maxLength={40}
onChange={(event) => updatePage({
headerLabel: event.currentTarget.value,
})}
/>
<TextField
label="Надзаголовок"
value={selectedPage.eyebrow}
maxLength={80}
onChange={(event) => updatePage({
eyebrow: event.currentTarget.value,
})}
/>
<TextField
label="Основной заголовок"
value={selectedPage.title}
maxLength={120}
onChange={(event) => updatePage({
title: event.currentTarget.value,
})}
/>
<TextAreaField
label="Описание"
value={selectedPage.description}
maxLength={500}
rows={3}
onChange={(event) => updatePage({
description: event.currentTarget.value,
})}
/>
</div>
<div className="environment-settings__quick-actions">
<div>
<span>Кнопка 1</span>
<Select
label="Выбрать первую быструю кнопку"
value={selectedPage.primaryWorkspaceId ?? "none"}
options={quickActionOptions}
variant="split"
menuWidth="anchor"
onChange={(value) => updatePage({
primaryWorkspaceId: value === "none" ? null : value,
})}
/>
</div>
<div>
<span>Кнопка 2</span>
<Select
label="Выбрать вторую быструю кнопку"
value={selectedPage.secondaryWorkspaceId ?? "none"}
options={quickActionOptions}
variant="split"
menuWidth="anchor"
onChange={(value) => updatePage({
secondaryWorkspaceId: value === "none" ? null : value,
})}
/>
</div>
</div>
<MediaSourceField
label="Видео / картинка"
kindLabel={previewKind ?? "media"}