feat(control-station): configure page-specific shell
This commit is contained in:
@@ -201,6 +201,17 @@ export default function App() {
|
||||
|
||||
const currentRoot = rootById(activeRoot);
|
||||
const visibleRoots = roots;
|
||||
const activeEnvironmentSurface = activeRoot ?? "home";
|
||||
const landingPage = environment.settings.pages[activeEnvironmentSurface];
|
||||
const landingQuickActions = [
|
||||
landingPage.primaryWorkspaceId,
|
||||
landingPage.secondaryWorkspaceId,
|
||||
].flatMap((workspaceId) => {
|
||||
const definition = workspaceById(workspaceId);
|
||||
if (!definition) return [];
|
||||
if (activeRoot !== null && definition.root !== activeRoot) return [];
|
||||
return [definition];
|
||||
});
|
||||
const activeDefinition = workspaceById(workspace.activeView);
|
||||
const sceneWorkspaceActive = Boolean(
|
||||
workspace.contentOpen
|
||||
@@ -634,7 +645,7 @@ export default function App() {
|
||||
value={activeRoot ?? undefined}
|
||||
items={visibleRoots.map((root) => ({
|
||||
value: root.id,
|
||||
label: environment.settings.headerLabels[root.id],
|
||||
label: environment.settings.pages[root.id].headerLabel,
|
||||
}))}
|
||||
onChange={selectRoot}
|
||||
/>
|
||||
@@ -684,11 +695,9 @@ export default function App() {
|
||||
backendStatus={runtime.backendStatus}
|
||||
phase={runtime.state?.phase}
|
||||
message={runtime.state?.message}
|
||||
background={
|
||||
environment.settings.backgrounds[activeRoot ?? "home"]
|
||||
}
|
||||
onOpenObservation={() => openView("spatial-scene")}
|
||||
onOpenDevice={() => openView("local-device")}
|
||||
page={landingPage}
|
||||
quickActions={landingQuickActions}
|
||||
onOpenWorkspace={openView}
|
||||
/>
|
||||
}
|
||||
navigation={currentRoot ? (
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Button, Icon, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import type { BackendStatus, RuntimePhase } from "../core/runtime/contracts";
|
||||
import type { EnvironmentBackground } from "../core/environment/environmentSettings";
|
||||
import type { RootDefinition } from "../productModel";
|
||||
import type { EnvironmentPage } from "../core/environment/environmentSettings";
|
||||
import type { RootDefinition, WorkspaceDefinition } from "../productModel";
|
||||
import { backendLabel, backendTone, phaseLabel, phaseTone } from "../presentation";
|
||||
|
||||
export interface LandingStageProps {
|
||||
@@ -10,9 +10,9 @@ export interface LandingStageProps {
|
||||
backendStatus: BackendStatus;
|
||||
phase?: RuntimePhase | null;
|
||||
message?: string | null;
|
||||
background: EnvironmentBackground;
|
||||
onOpenObservation: () => void;
|
||||
onOpenDevice: () => void;
|
||||
page: EnvironmentPage;
|
||||
quickActions: readonly WorkspaceDefinition[];
|
||||
onOpenWorkspace: (workspaceId: string) => void;
|
||||
}
|
||||
|
||||
export function LandingStage({
|
||||
@@ -20,10 +20,11 @@ export function LandingStage({
|
||||
backendStatus,
|
||||
phase,
|
||||
message,
|
||||
background,
|
||||
onOpenObservation,
|
||||
onOpenDevice,
|
||||
page,
|
||||
quickActions,
|
||||
onOpenWorkspace,
|
||||
}: LandingStageProps) {
|
||||
const { background } = page;
|
||||
return (
|
||||
<section
|
||||
className="landing-stage"
|
||||
@@ -48,20 +49,23 @@ export function LandingStage({
|
||||
) : null}
|
||||
<div className="landing-stage__shade" aria-hidden="true" />
|
||||
<div className="landing-stage__copy">
|
||||
<span className="section-eyebrow">{root?.eyebrow ?? "NODEDC / MISSION CORE"}</span>
|
||||
<h1>{root?.title ?? "Mission Core"}</h1>
|
||||
<p>
|
||||
{root?.statement ??
|
||||
"Наблюдение, планирование и корректировка миссий в одной модульной рабочей области."}
|
||||
</p>
|
||||
<div className="landing-stage__actions">
|
||||
<Button variant="primary" icon={<Icon name="globe" />} onClick={onOpenObservation}>
|
||||
Пространственная сцена
|
||||
</Button>
|
||||
<Button variant="secondary" icon={<Icon name="network" />} onClick={onOpenDevice}>
|
||||
Локальное устройство
|
||||
</Button>
|
||||
</div>
|
||||
<span className="section-eyebrow">{page.eyebrow}</span>
|
||||
<h1>{page.title}</h1>
|
||||
<p>{page.description}</p>
|
||||
{quickActions.length ? (
|
||||
<div className="landing-stage__actions">
|
||||
{quickActions.map((workspace, index) => (
|
||||
<Button
|
||||
key={workspace.id}
|
||||
variant={index === 0 ? "primary" : "secondary"}
|
||||
icon={<Icon name={workspace.icon} />}
|
||||
onClick={() => onOpenWorkspace(workspace.id)}
|
||||
>
|
||||
{workspace.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="landing-stage__status">
|
||||
|
||||
@@ -12,10 +12,19 @@ export interface EnvironmentBackground {
|
||||
fileName: string | null;
|
||||
}
|
||||
|
||||
export interface EnvironmentPage {
|
||||
headerLabel: string;
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryWorkspaceId: string | null;
|
||||
secondaryWorkspaceId: string | null;
|
||||
background: EnvironmentBackground;
|
||||
}
|
||||
|
||||
export interface EnvironmentSettings {
|
||||
revision: number;
|
||||
headerLabels: Record<RootId, string>;
|
||||
backgrounds: Record<EnvironmentSurfaceId, EnvironmentBackground>;
|
||||
pages: Record<EnvironmentSurfaceId, EnvironmentPage>;
|
||||
}
|
||||
|
||||
export interface UploadedEnvironmentMedia {
|
||||
@@ -30,7 +39,6 @@ export interface UploadedEnvironmentMedia {
|
||||
|
||||
const surfaceIds: readonly EnvironmentSurfaceId[] = [
|
||||
"home",
|
||||
"center",
|
||||
"fleet",
|
||||
"observation",
|
||||
"missions",
|
||||
@@ -39,8 +47,6 @@ const surfaceIds: readonly EnvironmentSurfaceId[] = [
|
||||
"polygon",
|
||||
];
|
||||
|
||||
const rootIds = surfaceIds.filter((value): value is RootId => value !== "home");
|
||||
|
||||
function emptyBackground(): EnvironmentBackground {
|
||||
return {
|
||||
enabled: false,
|
||||
@@ -51,15 +57,37 @@ function emptyBackground(): EnvironmentBackground {
|
||||
};
|
||||
}
|
||||
|
||||
const defaultQuickActions: Record<
|
||||
EnvironmentSurfaceId,
|
||||
readonly [string | null, string | null]
|
||||
> = {
|
||||
home: ["spatial-scene", "local-device"],
|
||||
fleet: ["contour-health", "local-device"],
|
||||
observation: ["spatial-scene", "cameras"],
|
||||
missions: ["mission-planner", null],
|
||||
data: ["recordings", "datasets"],
|
||||
system: ["modules", "integrations"],
|
||||
polygon: ["lab-archive", null],
|
||||
};
|
||||
|
||||
export function defaultEnvironmentSettings(): EnvironmentSettings {
|
||||
const rootById = new Map(roots.map((root) => [root.id, root]));
|
||||
return {
|
||||
revision: 0,
|
||||
headerLabels: Object.fromEntries(
|
||||
roots.map((root) => [root.id, root.label]),
|
||||
) as Record<RootId, string>,
|
||||
backgrounds: Object.fromEntries(
|
||||
surfaceIds.map((surfaceId) => [surfaceId, emptyBackground()]),
|
||||
) as Record<EnvironmentSurfaceId, EnvironmentBackground>,
|
||||
pages: Object.fromEntries(surfaceIds.map((surfaceId) => {
|
||||
const root = surfaceId === "home" ? null : rootById.get(surfaceId) ?? null;
|
||||
const [primaryWorkspaceId, secondaryWorkspaceId] = defaultQuickActions[surfaceId];
|
||||
return [surfaceId, {
|
||||
headerLabel: root?.label ?? "Mission Core",
|
||||
eyebrow: root?.eyebrow ?? "NODEDC / MISSION CORE",
|
||||
title: root?.title ?? "Mission Core",
|
||||
description: root?.statement
|
||||
?? "Наблюдение, планирование и корректировка миссий в одной модульной рабочей области.",
|
||||
primaryWorkspaceId,
|
||||
secondaryWorkspaceId,
|
||||
background: emptyBackground(),
|
||||
}];
|
||||
})) as Record<EnvironmentSurfaceId, EnvironmentPage>,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -70,7 +98,11 @@ function requireRecord(value: unknown, path: string): Record<string, unknown> {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function requireString(value: unknown, path: string, nullable = false): string | null {
|
||||
function requireString(
|
||||
value: unknown,
|
||||
path: string,
|
||||
nullable = false,
|
||||
): string | null {
|
||||
if (nullable && value === null) return null;
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new Error(`${path} должен быть непустой строкой.`);
|
||||
@@ -80,7 +112,9 @@ function requireString(value: unknown, path: string, nullable = false): string |
|
||||
|
||||
function decodeBackground(value: unknown, path: string): EnvironmentBackground {
|
||||
const record = requireRecord(value, path);
|
||||
if (typeof record.enabled !== "boolean") throw new Error(`${path}.enabled должен быть boolean.`);
|
||||
if (typeof record.enabled !== "boolean") {
|
||||
throw new Error(`${path}.enabled должен быть boolean.`);
|
||||
}
|
||||
if (!["file", "url"].includes(String(record.source))) {
|
||||
throw new Error(`${path}.source не поддерживается.`);
|
||||
}
|
||||
@@ -99,9 +133,30 @@ function decodeBackground(value: unknown, path: string): EnvironmentBackground {
|
||||
};
|
||||
}
|
||||
|
||||
function decodePage(value: unknown, path: string): EnvironmentPage {
|
||||
const record = requireRecord(value, path);
|
||||
return {
|
||||
headerLabel: requireString(record.header_label, `${path}.header_label`)!,
|
||||
eyebrow: requireString(record.eyebrow, `${path}.eyebrow`)!,
|
||||
title: requireString(record.title, `${path}.title`)!,
|
||||
description: requireString(record.description, `${path}.description`)!,
|
||||
primaryWorkspaceId: requireString(
|
||||
record.primary_workspace_id,
|
||||
`${path}.primary_workspace_id`,
|
||||
true,
|
||||
),
|
||||
secondaryWorkspaceId: requireString(
|
||||
record.secondary_workspace_id,
|
||||
`${path}.secondary_workspace_id`,
|
||||
true,
|
||||
),
|
||||
background: decodeBackground(record.background, `${path}.background`),
|
||||
};
|
||||
}
|
||||
|
||||
export function decodeEnvironmentSettings(value: unknown): EnvironmentSettings {
|
||||
const record = requireRecord(value, "environment");
|
||||
if (record.schema_version !== "missioncore.operator-environment/v1") {
|
||||
if (record.schema_version !== "missioncore.operator-environment/v2") {
|
||||
throw new Error("Версия настроек окружения не поддерживается.");
|
||||
}
|
||||
if (
|
||||
@@ -111,36 +166,35 @@ export function decodeEnvironmentSettings(value: unknown): EnvironmentSettings {
|
||||
) {
|
||||
throw new Error("Ревизия настроек окружения некорректна.");
|
||||
}
|
||||
const labels = requireRecord(record.header_labels, "environment.header_labels");
|
||||
const backgrounds = requireRecord(record.backgrounds, "environment.backgrounds");
|
||||
const pages = requireRecord(record.pages, "environment.pages");
|
||||
return {
|
||||
revision: record.revision,
|
||||
headerLabels: Object.fromEntries(rootIds.map((rootId) => [
|
||||
rootId,
|
||||
requireString(labels[rootId], `environment.header_labels.${rootId}`),
|
||||
])) as Record<RootId, string>,
|
||||
backgrounds: Object.fromEntries(surfaceIds.map((surfaceId) => [
|
||||
pages: Object.fromEntries(surfaceIds.map((surfaceId) => [
|
||||
surfaceId,
|
||||
decodeBackground(
|
||||
backgrounds[surfaceId],
|
||||
`environment.backgrounds.${surfaceId}`,
|
||||
),
|
||||
])) as Record<EnvironmentSurfaceId, EnvironmentBackground>,
|
||||
decodePage(pages[surfaceId], `environment.pages.${surfaceId}`),
|
||||
])) as Record<EnvironmentSurfaceId, EnvironmentPage>,
|
||||
};
|
||||
}
|
||||
|
||||
export function encodeEnvironmentSettings(settings: EnvironmentSettings): unknown {
|
||||
return {
|
||||
revision: settings.revision,
|
||||
header_labels: settings.headerLabels,
|
||||
backgrounds: Object.fromEntries(surfaceIds.map((surfaceId) => {
|
||||
const background = settings.backgrounds[surfaceId];
|
||||
pages: Object.fromEntries(surfaceIds.map((surfaceId) => {
|
||||
const page = settings.pages[surfaceId];
|
||||
return [surfaceId, {
|
||||
enabled: background.enabled,
|
||||
source: background.source,
|
||||
url: background.url,
|
||||
media_kind: background.mediaKind,
|
||||
file_name: background.fileName,
|
||||
header_label: page.headerLabel,
|
||||
eyebrow: page.eyebrow,
|
||||
title: page.title,
|
||||
description: page.description,
|
||||
primary_workspace_id: page.primaryWorkspaceId,
|
||||
secondary_workspace_id: page.secondaryWorkspaceId,
|
||||
background: {
|
||||
enabled: page.background.enabled,
|
||||
source: page.background.source,
|
||||
url: page.background.url,
|
||||
media_kind: page.background.mediaKind,
|
||||
file_name: page.background.fileName,
|
||||
},
|
||||
}];
|
||||
})),
|
||||
};
|
||||
@@ -182,11 +236,13 @@ export function cloneEnvironmentSettings(
|
||||
): EnvironmentSettings {
|
||||
return {
|
||||
revision: settings.revision,
|
||||
headerLabels: { ...settings.headerLabels },
|
||||
backgrounds: Object.fromEntries(surfaceIds.map((surfaceId) => [
|
||||
pages: Object.fromEntries(surfaceIds.map((surfaceId) => [
|
||||
surfaceId,
|
||||
{ ...settings.backgrounds[surfaceId] },
|
||||
])) as Record<EnvironmentSurfaceId, EnvironmentBackground>,
|
||||
{
|
||||
...settings.pages[surfaceId],
|
||||
background: { ...settings.pages[surfaceId].background },
|
||||
},
|
||||
])) as Record<EnvironmentSurfaceId, EnvironmentPage>,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { IconName } from "@nodedc/ui-react";
|
||||
|
||||
export type RootId =
|
||||
| "center"
|
||||
| "fleet"
|
||||
| "observation"
|
||||
| "missions"
|
||||
@@ -84,15 +83,6 @@ const later = (label: string, description: string): Capability => ({
|
||||
});
|
||||
|
||||
export const roots: RootDefinition[] = [
|
||||
{
|
||||
id: "center",
|
||||
label: "Центр",
|
||||
title: "Центр управления",
|
||||
eyebrow: "ПУНКТ УПРАВЛЕНИЯ",
|
||||
description: "Единая оперативная картина, состояние контура и события оператора.",
|
||||
statement: "Наблюдать весь контур, быстро видеть отклонения и переходить к действию.",
|
||||
accent: "ОПЕРАТИВНЫЙ КОНТУР",
|
||||
},
|
||||
{
|
||||
id: "fleet",
|
||||
label: "Парк",
|
||||
@@ -163,10 +153,10 @@ export const workspaces: WorkspaceDefinition[] = [
|
||||
},
|
||||
{
|
||||
id: "contour-health",
|
||||
root: "center",
|
||||
root: "fleet",
|
||||
label: "Состояние контура",
|
||||
title: "Состояние контура",
|
||||
eyebrow: "ЦЕНТР / СОСТОЯНИЕ",
|
||||
eyebrow: "ПАРК / СОСТОЯНИЕ",
|
||||
description: "Узлы, процессы, сеть и подключённые устройства по данным живого контура.",
|
||||
icon: "shield",
|
||||
kind: "contour-health",
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.environment-settings__labels {
|
||||
.environment-settings__copy {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.environment-settings__media {
|
||||
.environment-settings__editor {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
@@ -32,8 +32,31 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.environment-settings__quick-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.environment-settings__quick-actions > div {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.environment-settings__quick-actions span {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.environment-settings__quick-actions .nodedc-select-anchor,
|
||||
.environment-settings__quick-actions .nodedc-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.environment-settings__labels {
|
||||
.environment-settings__copy,
|
||||
.environment-settings__quick-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,18 +24,25 @@ after(async () => {
|
||||
function serverDocument(overrides = {}) {
|
||||
const defaults = environment.defaultEnvironmentSettings();
|
||||
return {
|
||||
schema_version: "missioncore.operator-environment/v1",
|
||||
schema_version: "missioncore.operator-environment/v2",
|
||||
revision: defaults.revision,
|
||||
header_labels: defaults.headerLabels,
|
||||
backgrounds: Object.fromEntries(
|
||||
Object.entries(defaults.backgrounds).map(([surfaceId, background]) => [
|
||||
pages: Object.fromEntries(
|
||||
Object.entries(defaults.pages).map(([surfaceId, page]) => [
|
||||
surfaceId,
|
||||
{
|
||||
enabled: background.enabled,
|
||||
source: background.source,
|
||||
url: background.url,
|
||||
media_kind: background.mediaKind,
|
||||
file_name: background.fileName,
|
||||
header_label: page.headerLabel,
|
||||
eyebrow: page.eyebrow,
|
||||
title: page.title,
|
||||
description: page.description,
|
||||
primary_workspace_id: page.primaryWorkspaceId,
|
||||
secondary_workspace_id: page.secondaryWorkspaceId,
|
||||
background: {
|
||||
enabled: page.background.enabled,
|
||||
source: page.background.source,
|
||||
url: page.background.url,
|
||||
media_kind: page.background.mediaKind,
|
||||
file_name: page.background.fileName,
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
@@ -43,20 +50,10 @@ function serverDocument(overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
test("default environment exposes every header and landing surface", () => {
|
||||
test("default environment exposes every current product page", () => {
|
||||
const defaults = environment.defaultEnvironmentSettings();
|
||||
assert.deepEqual(Object.keys(defaults.headerLabels), [
|
||||
"center",
|
||||
"fleet",
|
||||
"observation",
|
||||
"missions",
|
||||
"data",
|
||||
"system",
|
||||
"polygon",
|
||||
]);
|
||||
assert.deepEqual(Object.keys(defaults.backgrounds), [
|
||||
assert.deepEqual(Object.keys(defaults.pages), [
|
||||
"home",
|
||||
"center",
|
||||
"fleet",
|
||||
"observation",
|
||||
"missions",
|
||||
@@ -64,23 +61,31 @@ test("default environment exposes every header and landing surface", () => {
|
||||
"system",
|
||||
"polygon",
|
||||
]);
|
||||
assert.equal(defaults.pages.fleet.primaryWorkspaceId, "contour-health");
|
||||
assert.equal(defaults.pages.observation.primaryWorkspaceId, "spatial-scene");
|
||||
});
|
||||
|
||||
test("server document decodes and re-encodes without leaking schema internals", () => {
|
||||
test("server document decodes and re-encodes the complete page contract", () => {
|
||||
const base = serverDocument();
|
||||
const payload = serverDocument({
|
||||
revision: 4,
|
||||
header_labels: {
|
||||
...serverDocument().header_labels,
|
||||
center: "Командный центр",
|
||||
},
|
||||
backgrounds: {
|
||||
...serverDocument().backgrounds,
|
||||
home: {
|
||||
enabled: true,
|
||||
source: "file",
|
||||
url: `/api/v1/environment/media/home?generation=${"a".repeat(64)}`,
|
||||
media_kind: "video",
|
||||
file_name: "stage.mp4",
|
||||
pages: {
|
||||
...base.pages,
|
||||
observation: {
|
||||
...base.pages.observation,
|
||||
header_label: "Контроль",
|
||||
title: "Контроль пространства",
|
||||
},
|
||||
fleet: {
|
||||
...base.pages.fleet,
|
||||
primary_workspace_id: "contour-health",
|
||||
background: {
|
||||
enabled: true,
|
||||
source: "file",
|
||||
url: `/api/v1/environment/media/fleet?generation=${"a".repeat(64)}`,
|
||||
media_kind: "video",
|
||||
file_name: "stage.mp4",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -88,9 +93,11 @@ test("server document decodes and re-encodes without leaking schema internals",
|
||||
const encoded = environment.encodeEnvironmentSettings(decoded);
|
||||
|
||||
assert.equal(decoded.revision, 4);
|
||||
assert.equal(decoded.headerLabels.center, "Командный центр");
|
||||
assert.equal(decoded.backgrounds.home.mediaKind, "video");
|
||||
assert.equal(encoded.backgrounds.home.media_kind, "video");
|
||||
assert.equal(decoded.pages.observation.headerLabel, "Контроль");
|
||||
assert.equal(decoded.pages.observation.title, "Контроль пространства");
|
||||
assert.equal(decoded.pages.fleet.background.mediaKind, "video");
|
||||
assert.equal(encoded.pages.fleet.background.media_kind, "video");
|
||||
assert.equal(encoded.pages.fleet.primary_workspace_id, "contour-health");
|
||||
assert.equal("schema_version" in encoded, false);
|
||||
});
|
||||
|
||||
@@ -103,12 +110,12 @@ test("decoder fails closed on an unknown environment schema", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("editing a cloned environment cannot mutate the accepted settings", () => {
|
||||
test("editing a cloned page cannot mutate accepted settings", () => {
|
||||
const accepted = environment.decodeEnvironmentSettings(serverDocument());
|
||||
const draft = environment.cloneEnvironmentSettings(accepted);
|
||||
draft.headerLabels.center = "Изменено";
|
||||
draft.backgrounds.home.enabled = true;
|
||||
draft.pages.observation.headerLabel = "Изменено";
|
||||
draft.pages.fleet.background.enabled = true;
|
||||
|
||||
assert.equal(accepted.headerLabels.center, "Центр");
|
||||
assert.equal(accepted.backgrounds.home.enabled, false);
|
||||
assert.equal(accepted.pages.observation.headerLabel, "Наблюдение");
|
||||
assert.equal(accepted.pages.fleet.background.enabled, false);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user