From 360ec39ff3bbe05c92287fa31b184ab56dcb869d Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sun, 26 Jul 2026 17:06:04 +0300 Subject: [PATCH] feat(control-station): configure page-specific shell --- apps/control-station/src/App.tsx | 21 +- .../components/EnvironmentSettingsWindow.tsx | 240 ++++++++++++------ .../src/components/LandingStage.tsx | 48 ++-- .../core/environment/environmentSettings.ts | 132 +++++++--- apps/control-station/src/productModel.ts | 14 +- .../src/styles/environment-settings.css | 29 ++- .../test/environmentSettings.test.mjs | 91 ++++--- src/k1link/web/environment_api.py | 207 +++++++++++---- tests/test_environment_api.py | 96 ++++++- 9 files changed, 619 insertions(+), 259 deletions(-) diff --git a/apps/control-station/src/App.tsx b/apps/control-station/src/App.tsx index 92fb969..a7bc5fb 100644 --- a/apps/control-station/src/App.tsx +++ b/apps/control-station/src/App.tsx @@ -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 ? ( diff --git a/apps/control-station/src/components/EnvironmentSettingsWindow.tsx b/apps/control-station/src/components/EnvironmentSettingsWindow.tsx index bd4f7ed..f77ceba 100644 --- a/apps/control-station/src/components/EnvironmentSettingsWindow.tsx +++ b/apps/control-station/src/components/EnvironmentSettingsWindow.tsx @@ -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; } -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, +): EnvironmentSettings { + return { + ...draft, + pages: { + ...draft.pages, + [surfaceId]: { + ...draft.pages[surfaceId], + ...patch, + }, + }, + }; +} + function patchBackground( draft: EnvironmentSettings, surfaceId: EnvironmentSurfaceId, patch: Partial, ): 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("home"); + const [surfaceId, setSurfaceId] = useState("fleet"); const [uploading, setUploading] = useState(false); const [localError, setLocalError] = useState(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) => { + 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, + 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({ >
-
- {headerLabelFields.map((field) => ( - updateHeaderLabel(field.id, event.currentTarget.value)} - /> - ))} -
-
- - { if (enabled && !selectedBackground.url) { setLocalError("Сначала загрузите файл или укажите URL."); @@ -244,13 +264,13 @@ export function EnvironmentSettingsWindow({ /> )} > -
+
- Экран + Страница updatePage({ + primaryWorkspaceId: value === "none" ? null : value, + })} + /> +
+
+ Кнопка 2 +