feat(control-station): add configurable environment shell
This commit is contained in:
@@ -8,10 +8,8 @@ import {
|
||||
Checker,
|
||||
ColorField,
|
||||
ControlRow,
|
||||
HeaderAvatar,
|
||||
HeaderNavigation,
|
||||
HeaderProfile,
|
||||
HeaderProfileButton,
|
||||
HeaderWorkspace,
|
||||
Icon,
|
||||
Inspector,
|
||||
@@ -19,6 +17,7 @@ import {
|
||||
Select,
|
||||
StatusBadge,
|
||||
TextField,
|
||||
UserProfileMenu,
|
||||
Window,
|
||||
WindowFooterActions,
|
||||
useApplicationWorkspace,
|
||||
@@ -26,7 +25,9 @@ import {
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { LandingStage } from "./components/LandingStage";
|
||||
import { EnvironmentSettingsWindow } from "./components/EnvironmentSettingsWindow";
|
||||
import { ObservationSessionSelect } from "./components/ObservationSessionSelect";
|
||||
import { useEnvironmentSettings } from "./core/environment/useEnvironmentSettings";
|
||||
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
|
||||
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
|
||||
import type { ViewerSettings } from "./core/runtime/contracts";
|
||||
@@ -149,6 +150,7 @@ function mergeViewerSettings(
|
||||
|
||||
export default function App() {
|
||||
const runtime = useMissionRuntime();
|
||||
const environment = useEnvironmentSettings();
|
||||
const { selection } = useDevicePluginHost();
|
||||
const polygonDatasetRoute = useMemo(
|
||||
() => resolvePolygonRunRoute(typeof window === "undefined" ? "" : window.location.search),
|
||||
@@ -162,6 +164,7 @@ export default function App() {
|
||||
const [activeRoot, setActiveRoot] = useState<RootId | null>(
|
||||
polygonDatasetRoute.active ? "data" : null,
|
||||
);
|
||||
const [environmentSettingsOpen, setEnvironmentSettingsOpen] = useState(false);
|
||||
const [sourceUrl, setSourceUrl] = useState("");
|
||||
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
|
||||
const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(null);
|
||||
@@ -423,11 +426,6 @@ export default function App() {
|
||||
|
||||
const selectRoot = (rootId: RootId) => {
|
||||
setActiveRoot(rootId);
|
||||
if (rootId === "polygon") {
|
||||
workspace.openView("lab-archive");
|
||||
workspace.openNavigation();
|
||||
return;
|
||||
}
|
||||
workspace.closeView();
|
||||
workspace.openNavigation();
|
||||
};
|
||||
@@ -626,6 +624,7 @@ export default function App() {
|
||||
const header = (
|
||||
<AppHeader
|
||||
brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />}
|
||||
brandHref="/"
|
||||
brandLabel="NODEDC MISSION CORE"
|
||||
center={
|
||||
<>
|
||||
@@ -633,18 +632,35 @@ export default function App() {
|
||||
<HeaderNavigation
|
||||
label="Архитектурные блоки пункта управления"
|
||||
value={activeRoot ?? undefined}
|
||||
items={visibleRoots.map((root) => ({ value: root.id, label: root.label }))}
|
||||
items={visibleRoots.map((root) => ({
|
||||
value: root.id,
|
||||
label: environment.settings.headerLabels[root.id],
|
||||
}))}
|
||||
onChange={selectRoot}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
right={
|
||||
<HeaderProfile>
|
||||
<HeaderProfileButton onClick={() => void runtime.refresh()} title="Обновить локальный контур">
|
||||
<span className="api-dot" data-status={runtime.backendStatus} aria-hidden="true" />
|
||||
{backendLabel(runtime.backendStatus)}
|
||||
</HeaderProfileButton>
|
||||
<HeaderAvatar label="DC" />
|
||||
<UserProfileMenu
|
||||
displayName="DC"
|
||||
subtitle="Mission Core"
|
||||
triggerLabel={null}
|
||||
actions={[
|
||||
{
|
||||
id: "refresh",
|
||||
label: "Обновить контур",
|
||||
icon: "refresh",
|
||||
onSelect: () => void runtime.refresh(),
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
label: "Настройки",
|
||||
icon: "settings",
|
||||
onSelect: () => setEnvironmentSettingsOpen(true),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</HeaderProfile>
|
||||
}
|
||||
/>
|
||||
@@ -668,6 +684,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")}
|
||||
/>
|
||||
@@ -764,7 +783,7 @@ export default function App() {
|
||||
) : activeDefinition.kind === "datasets" ? (
|
||||
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
|
||||
) : activeDefinition.kind === "lab-archive" ? (
|
||||
<StatusBadge tone="accent">Лаборатория</StatusBadge>
|
||||
null
|
||||
) : (
|
||||
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
|
||||
)
|
||||
@@ -821,6 +840,16 @@ export default function App() {
|
||||
) : null}
|
||||
/>
|
||||
|
||||
<EnvironmentSettingsWindow
|
||||
open={environmentSettingsOpen}
|
||||
settings={environment.settings}
|
||||
state={environment.state}
|
||||
error={environment.error}
|
||||
onClose={() => setEnvironmentSettingsOpen(false)}
|
||||
onSave={environment.save}
|
||||
onUpload={environment.upload}
|
||||
/>
|
||||
|
||||
<Window
|
||||
open={sceneWorkspaceActive && sourceWindowOpen}
|
||||
title="Визуальный движок"
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
FeatureSettingsWindow,
|
||||
MediaSourceField,
|
||||
Select,
|
||||
SettingsCard,
|
||||
Switch,
|
||||
TextField,
|
||||
WindowFooterActions,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
cloneEnvironmentSettings,
|
||||
type EnvironmentBackground,
|
||||
type EnvironmentMediaKind,
|
||||
type EnvironmentSettings,
|
||||
type EnvironmentSurfaceId,
|
||||
type UploadedEnvironmentMedia,
|
||||
} from "../core/environment/environmentSettings";
|
||||
import { roots, type RootId } from "../productModel";
|
||||
|
||||
interface EnvironmentSettingsWindowProps {
|
||||
open: boolean;
|
||||
settings: EnvironmentSettings;
|
||||
state: "loading" | "ready" | "saving" | "error";
|
||||
error: string | null;
|
||||
onClose: () => void;
|
||||
onSave: (settings: EnvironmentSettings) => Promise<EnvironmentSettings>;
|
||||
onUpload: (
|
||||
surfaceId: EnvironmentSurfaceId,
|
||||
file: File,
|
||||
) => 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 patchBackground(
|
||||
draft: EnvironmentSettings,
|
||||
surfaceId: EnvironmentSurfaceId,
|
||||
patch: Partial<EnvironmentBackground>,
|
||||
): EnvironmentSettings {
|
||||
return {
|
||||
...draft,
|
||||
backgrounds: {
|
||||
...draft.backgrounds,
|
||||
[surfaceId]: {
|
||||
...draft.backgrounds[surfaceId],
|
||||
...patch,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function EnvironmentSettingsWindow({
|
||||
open,
|
||||
settings,
|
||||
state,
|
||||
error,
|
||||
onClose,
|
||||
onSave,
|
||||
onUpload,
|
||||
}: EnvironmentSettingsWindowProps) {
|
||||
const [draft, setDraft] = useState(() => cloneEnvironmentSettings(settings));
|
||||
const [surfaceId, setSurfaceId] = useState<EnvironmentSurfaceId>("home");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setDraft(cloneEnvironmentSettings(settings));
|
||||
setLocalError(null);
|
||||
}, [open, settings]);
|
||||
|
||||
const selectedBackground = draft.backgrounds[surfaceId];
|
||||
const previewKind = selectedBackground.mediaKind
|
||||
?? (selectedBackground.url ? inferMediaKind(selectedBackground.url) : null);
|
||||
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 uploadFile = async (file?: File) => {
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
setLocalError(null);
|
||||
try {
|
||||
const uploaded = await onUpload(surfaceId, file);
|
||||
setDraft((current) => patchBackground(current, surfaceId, {
|
||||
enabled: true,
|
||||
source: "file",
|
||||
url: uploaded.url,
|
||||
mediaKind: uploaded.mediaKind,
|
||||
fileName: uploaded.fileName,
|
||||
}));
|
||||
} catch (reason) {
|
||||
setLocalError(reason instanceof Error
|
||||
? reason.message
|
||||
: "Не удалось загрузить фон окружения.");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const blank = headerLabelFields.find(({ id }) => !draft.headerLabels[id].trim());
|
||||
if (blank) {
|
||||
setLocalError(`Название «${blank.label}» не может быть пустым.`);
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
try {
|
||||
await onSave({
|
||||
...draft,
|
||||
headerLabels: Object.fromEntries(headerLabelFields.map(({ id }) => [
|
||||
id,
|
||||
draft.headerLabels[id].trim(),
|
||||
])) as Record<RootId, string>,
|
||||
});
|
||||
onClose();
|
||||
} catch (reason) {
|
||||
setLocalError(reason instanceof Error
|
||||
? reason.message
|
||||
: "Не удалось сохранить настройки окружения.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FeatureSettingsWindow
|
||||
open={open}
|
||||
title="Настройки Mission Core"
|
||||
subtitle="Локальное операторское окружение"
|
||||
identity={{
|
||||
title: "DC",
|
||||
subtitle: "Mission Core",
|
||||
avatarLabel: "DC",
|
||||
}}
|
||||
sections={[
|
||||
{
|
||||
id: "environment",
|
||||
label: "Окружение",
|
||||
group: "MISSION CORE",
|
||||
icon: "settings",
|
||||
},
|
||||
]}
|
||||
activeSection="environment"
|
||||
onSectionChange={() => undefined}
|
||||
onClose={onClose}
|
||||
footer={(
|
||||
<WindowFooterActions>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={!dirty || busy}
|
||||
onClick={() => {
|
||||
setDraft(cloneEnvironmentSettings(settings));
|
||||
setLocalError(null);
|
||||
}}
|
||||
>
|
||||
Сбросить изменения
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!dirty || busy}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{state === "saving" ? "Сохраняем…" : "Сохранить"}
|
||||
</Button>
|
||||
</WindowFooterActions>
|
||||
)}
|
||||
>
|
||||
<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 не затрагиваются."
|
||||
actions={(
|
||||
<Switch
|
||||
checked={selectedBackground.enabled}
|
||||
label="Показывать"
|
||||
onChange={(enabled) => {
|
||||
if (enabled && !selectedBackground.url) {
|
||||
setLocalError("Сначала загрузите файл или укажите URL.");
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
setDraft((current) =>
|
||||
patchBackground(current, surfaceId, { enabled }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<div className="environment-settings__media">
|
||||
<div className="environment-settings__surface">
|
||||
<span>Экран</span>
|
||||
<Select
|
||||
label="Выбрать стартовую страницу"
|
||||
value={surfaceId}
|
||||
options={backgroundSurfaceOptions}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => {
|
||||
setSurfaceId(value);
|
||||
setLocalError(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<MediaSourceField
|
||||
label="Видео / картинка"
|
||||
kindLabel={previewKind ?? "media"}
|
||||
source={selectedBackground.source}
|
||||
url={selectedBackground.url ?? ""}
|
||||
fileName={selectedBackground.fileName}
|
||||
uploading={uploading}
|
||||
previewSrc={selectedBackground.url}
|
||||
previewKind={previewKind}
|
||||
accept="image/png,image/jpeg,image/gif,image/webp,image/avif,video/mp4,video/webm,video/quicktime,.png,.jpg,.jpeg,.gif,.webp,.avif,.mp4,.webm,.mov"
|
||||
path={`${surfaceId}.background → server environment media`}
|
||||
hint="Файл сохраняется в Mission Core data root. URL должен быть доступен браузеру по HTTP(S)."
|
||||
error={localError ?? error}
|
||||
onSourceChange={(source) => setDraft((current) =>
|
||||
patchBackground(current, surfaceId, { source }))}
|
||||
onUrlChange={(url) => setDraft((current) =>
|
||||
patchBackground(current, surfaceId, {
|
||||
source: "url",
|
||||
url: url || null,
|
||||
mediaKind: url ? inferMediaKind(url) : null,
|
||||
fileName: null,
|
||||
}))}
|
||||
onFileChange={uploadFile}
|
||||
/>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
</FeatureSettingsWindow>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { backendLabel, backendTone, phaseLabel, phaseTone } from "../presentation";
|
||||
|
||||
@@ -9,6 +10,7 @@ export interface LandingStageProps {
|
||||
backendStatus: BackendStatus;
|
||||
phase?: RuntimePhase | null;
|
||||
message?: string | null;
|
||||
background: EnvironmentBackground;
|
||||
onOpenObservation: () => void;
|
||||
onOpenDevice: () => void;
|
||||
}
|
||||
@@ -18,11 +20,33 @@ export function LandingStage({
|
||||
backendStatus,
|
||||
phase,
|
||||
message,
|
||||
background,
|
||||
onOpenObservation,
|
||||
onOpenDevice,
|
||||
}: LandingStageProps) {
|
||||
return (
|
||||
<section className="landing-stage" data-root={root?.id ?? "home"}>
|
||||
<section
|
||||
className="landing-stage"
|
||||
data-root={root?.id ?? "home"}
|
||||
data-has-media={background.enabled && background.url ? "true" : undefined}
|
||||
>
|
||||
{background.enabled && background.url ? (
|
||||
<div className="landing-stage__media" aria-hidden="true">
|
||||
{background.mediaKind === "video" ? (
|
||||
<video
|
||||
key={background.url}
|
||||
src={background.url}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
/>
|
||||
) : (
|
||||
<img src={background.url} alt="" />
|
||||
)}
|
||||
</div>
|
||||
) : 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>
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { roots, type RootId } from "../../productModel";
|
||||
|
||||
export type EnvironmentSurfaceId = "home" | RootId;
|
||||
export type EnvironmentMediaKind = "image" | "video";
|
||||
export type EnvironmentMediaSource = "file" | "url";
|
||||
|
||||
export interface EnvironmentBackground {
|
||||
enabled: boolean;
|
||||
source: EnvironmentMediaSource;
|
||||
url: string | null;
|
||||
mediaKind: EnvironmentMediaKind | null;
|
||||
fileName: string | null;
|
||||
}
|
||||
|
||||
export interface EnvironmentSettings {
|
||||
revision: number;
|
||||
headerLabels: Record<RootId, string>;
|
||||
backgrounds: Record<EnvironmentSurfaceId, EnvironmentBackground>;
|
||||
}
|
||||
|
||||
export interface UploadedEnvironmentMedia {
|
||||
surfaceId: EnvironmentSurfaceId;
|
||||
url: string;
|
||||
fileName: string;
|
||||
mediaKind: EnvironmentMediaKind;
|
||||
mediaType: string;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
const surfaceIds: readonly EnvironmentSurfaceId[] = [
|
||||
"home",
|
||||
"center",
|
||||
"fleet",
|
||||
"observation",
|
||||
"missions",
|
||||
"data",
|
||||
"system",
|
||||
"polygon",
|
||||
];
|
||||
|
||||
const rootIds = surfaceIds.filter((value): value is RootId => value !== "home");
|
||||
|
||||
function emptyBackground(): EnvironmentBackground {
|
||||
return {
|
||||
enabled: false,
|
||||
source: "file",
|
||||
url: null,
|
||||
mediaKind: null,
|
||||
fileName: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultEnvironmentSettings(): EnvironmentSettings {
|
||||
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>,
|
||||
};
|
||||
}
|
||||
|
||||
function requireRecord(value: unknown, path: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(`${path} должен быть объектом.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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} должен быть непустой строкой.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function decodeBackground(value: unknown, path: string): EnvironmentBackground {
|
||||
const record = requireRecord(value, path);
|
||||
if (typeof record.enabled !== "boolean") throw new Error(`${path}.enabled должен быть boolean.`);
|
||||
if (!["file", "url"].includes(String(record.source))) {
|
||||
throw new Error(`${path}.source не поддерживается.`);
|
||||
}
|
||||
if (
|
||||
record.media_kind !== null
|
||||
&& !["image", "video"].includes(String(record.media_kind))
|
||||
) {
|
||||
throw new Error(`${path}.media_kind не поддерживается.`);
|
||||
}
|
||||
return {
|
||||
enabled: record.enabled,
|
||||
source: record.source as EnvironmentMediaSource,
|
||||
url: requireString(record.url, `${path}.url`, true),
|
||||
mediaKind: record.media_kind as EnvironmentMediaKind | null,
|
||||
fileName: requireString(record.file_name, `${path}.file_name`, true),
|
||||
};
|
||||
}
|
||||
|
||||
export function decodeEnvironmentSettings(value: unknown): EnvironmentSettings {
|
||||
const record = requireRecord(value, "environment");
|
||||
if (record.schema_version !== "missioncore.operator-environment/v1") {
|
||||
throw new Error("Версия настроек окружения не поддерживается.");
|
||||
}
|
||||
if (
|
||||
typeof record.revision !== "number"
|
||||
|| !Number.isSafeInteger(record.revision)
|
||||
|| record.revision < 0
|
||||
) {
|
||||
throw new Error("Ревизия настроек окружения некорректна.");
|
||||
}
|
||||
const labels = requireRecord(record.header_labels, "environment.header_labels");
|
||||
const backgrounds = requireRecord(record.backgrounds, "environment.backgrounds");
|
||||
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) => [
|
||||
surfaceId,
|
||||
decodeBackground(
|
||||
backgrounds[surfaceId],
|
||||
`environment.backgrounds.${surfaceId}`,
|
||||
),
|
||||
])) as Record<EnvironmentSurfaceId, EnvironmentBackground>,
|
||||
};
|
||||
}
|
||||
|
||||
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];
|
||||
return [surfaceId, {
|
||||
enabled: background.enabled,
|
||||
source: background.source,
|
||||
url: background.url,
|
||||
media_kind: background.mediaKind,
|
||||
file_name: background.fileName,
|
||||
}];
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function decodeUploadedEnvironmentMedia(
|
||||
value: unknown,
|
||||
): UploadedEnvironmentMedia {
|
||||
const record = requireRecord(value, "environment media");
|
||||
if (record.schema_version !== "missioncore.operator-environment-media/v1") {
|
||||
throw new Error("Версия загруженного media не поддерживается.");
|
||||
}
|
||||
if (!surfaceIds.includes(record.surface_id as EnvironmentSurfaceId)) {
|
||||
throw new Error("Экран загруженного media не поддерживается.");
|
||||
}
|
||||
if (!["image", "video"].includes(String(record.media_kind))) {
|
||||
throw new Error("Тип загруженного media не поддерживается.");
|
||||
}
|
||||
if (
|
||||
typeof record.byte_length !== "number"
|
||||
|| !Number.isSafeInteger(record.byte_length)
|
||||
|| record.byte_length <= 0
|
||||
) {
|
||||
throw new Error("Размер загруженного media некорректен.");
|
||||
}
|
||||
return {
|
||||
surfaceId: record.surface_id as EnvironmentSurfaceId,
|
||||
url: requireString(record.url, "environment media.url")!,
|
||||
fileName: requireString(record.file_name, "environment media.file_name")!,
|
||||
mediaKind: record.media_kind as EnvironmentMediaKind,
|
||||
mediaType: requireString(record.media_type, "environment media.media_type")!,
|
||||
byteLength: record.byte_length,
|
||||
sha256: requireString(record.sha256, "environment media.sha256")!,
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneEnvironmentSettings(
|
||||
settings: EnvironmentSettings,
|
||||
): EnvironmentSettings {
|
||||
return {
|
||||
revision: settings.revision,
|
||||
headerLabels: { ...settings.headerLabels },
|
||||
backgrounds: Object.fromEntries(surfaceIds.map((surfaceId) => [
|
||||
surfaceId,
|
||||
{ ...settings.backgrounds[surfaceId] },
|
||||
])) as Record<EnvironmentSurfaceId, EnvironmentBackground>,
|
||||
};
|
||||
}
|
||||
|
||||
export const environmentSurfaceIds = surfaceIds;
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
decodeEnvironmentSettings,
|
||||
decodeUploadedEnvironmentMedia,
|
||||
defaultEnvironmentSettings,
|
||||
encodeEnvironmentSettings,
|
||||
type EnvironmentSettings,
|
||||
type EnvironmentSurfaceId,
|
||||
type UploadedEnvironmentMedia,
|
||||
} from "./environmentSettings";
|
||||
|
||||
interface EnvironmentSettingsController {
|
||||
settings: EnvironmentSettings;
|
||||
state: "loading" | "ready" | "saving" | "error";
|
||||
error: string | null;
|
||||
save: (draft: EnvironmentSettings) => Promise<EnvironmentSettings>;
|
||||
upload: (
|
||||
surfaceId: EnvironmentSurfaceId,
|
||||
file: File,
|
||||
) => Promise<UploadedEnvironmentMedia>;
|
||||
}
|
||||
|
||||
async function responseError(response: Response, fallback: string): Promise<string> {
|
||||
try {
|
||||
const body = await response.json() as { detail?: unknown };
|
||||
if (typeof body.detail === "string" && body.detail.trim()) return body.detail;
|
||||
} catch {
|
||||
// A non-JSON reverse-proxy response still gets a stable product message.
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function useEnvironmentSettings(): EnvironmentSettingsController {
|
||||
const [settings, setSettings] = useState<EnvironmentSettings>(
|
||||
defaultEnvironmentSettings,
|
||||
);
|
||||
const [state, setState] = useState<EnvironmentSettingsController["state"]>(
|
||||
"loading",
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void fetch("/api/v1/environment/settings", {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: "application/json" },
|
||||
}).then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(await responseError(
|
||||
response,
|
||||
"Не удалось загрузить настройки окружения.",
|
||||
));
|
||||
}
|
||||
return decodeEnvironmentSettings(await response.json());
|
||||
}).then((document) => {
|
||||
setSettings(document);
|
||||
setState("ready");
|
||||
setError(null);
|
||||
}).catch((reason: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setState("error");
|
||||
setError(reason instanceof Error
|
||||
? reason.message
|
||||
: "Не удалось загрузить настройки окружения.");
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
const save = useCallback(async (draft: EnvironmentSettings) => {
|
||||
setState("saving");
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch("/api/v1/environment/settings", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(encodeEnvironmentSettings(draft)),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await responseError(
|
||||
response,
|
||||
"Не удалось сохранить настройки окружения.",
|
||||
));
|
||||
}
|
||||
const document = decodeEnvironmentSettings(await response.json());
|
||||
setSettings(document);
|
||||
setState("ready");
|
||||
return document;
|
||||
} catch (reason) {
|
||||
const message = reason instanceof Error
|
||||
? reason.message
|
||||
: "Не удалось сохранить настройки окружения.";
|
||||
setState("error");
|
||||
setError(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const upload = useCallback(async (
|
||||
surfaceId: EnvironmentSurfaceId,
|
||||
file: File,
|
||||
) => {
|
||||
const response = await fetch(`/api/v1/environment/media/${surfaceId}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": file.type || "application/octet-stream",
|
||||
"X-NODEDC-File-Name": encodeURIComponent(file.name),
|
||||
},
|
||||
body: file,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await responseError(
|
||||
response,
|
||||
"Не удалось загрузить фон окружения.",
|
||||
));
|
||||
}
|
||||
return decodeUploadedEnvironmentMedia(await response.json());
|
||||
}, []);
|
||||
|
||||
return { settings, state, error, save, upload };
|
||||
}
|
||||
@@ -5,3 +5,4 @@
|
||||
@import "./styles/device.css";
|
||||
@import "./styles/responsive.css";
|
||||
@import "./styles/observation.css";
|
||||
@import "./styles/environment-settings.css";
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
.environment-settings {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.environment-settings__labels {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.environment-settings__media {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.environment-settings__surface {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(18rem, 24rem);
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.environment-settings__surface > span {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.environment-settings__surface .nodedc-select-anchor,
|
||||
.environment-settings__surface .nodedc-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.environment-settings__labels {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.environment-settings__surface {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,34 @@
|
||||
background: var(--station-stage);
|
||||
}
|
||||
|
||||
.landing-stage__media,
|
||||
.landing-stage__shade {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.landing-stage__media img,
|
||||
.landing-stage__media video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.landing-stage__shade {
|
||||
z-index: 1;
|
||||
background:
|
||||
linear-gradient(90deg, rgb(5 6 8 / 0.82) 0%, rgb(5 6 8 / 0.54) 46%, rgb(5 6 8 / 0.24) 100%),
|
||||
linear-gradient(0deg, rgb(5 6 8 / 0.58), transparent 38%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.landing-stage:not([data-has-media="true"]) .landing-stage__shade {
|
||||
background:
|
||||
radial-gradient(circle at 68% 42%, rgb(255 255 255 / 0.035), transparent 34%),
|
||||
linear-gradient(90deg, rgb(5 6 8 / 0.2), transparent 62%);
|
||||
}
|
||||
|
||||
.landing-stage__copy {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
|
||||
@@ -2441,12 +2441,12 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.laboratory-selector label {
|
||||
.laboratory-selector__control {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.laboratory-selector label > span {
|
||||
.laboratory-selector__control > span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.55rem;
|
||||
font-weight: 650;
|
||||
@@ -2454,26 +2454,9 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.laboratory-selector select {
|
||||
.laboratory-selector__control .nodedc-select-anchor,
|
||||
.laboratory-selector__control .nodedc-select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
outline: 0;
|
||||
background: rgb(255 255 255 / 0.075);
|
||||
color: var(--nodedc-text-primary);
|
||||
padding: 0.58rem 0.85rem;
|
||||
font: inherit;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 620;
|
||||
}
|
||||
|
||||
.laboratory-selector select:focus-visible {
|
||||
background: rgb(255 255 255 / 0.12);
|
||||
}
|
||||
|
||||
.laboratory-selector select:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.laboratory-work-output {
|
||||
@@ -2482,16 +2465,14 @@
|
||||
}
|
||||
|
||||
.laboratory-task,
|
||||
.laboratory-result-summary,
|
||||
.laboratory-visual-result {
|
||||
.laboratory-result-summary {
|
||||
border-radius: 1rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-task > header,
|
||||
.laboratory-result-summary > header,
|
||||
.laboratory-visual-result > header {
|
||||
.laboratory-result-summary > header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
@@ -2502,15 +2483,12 @@
|
||||
.laboratory-task p,
|
||||
.laboratory-task dl,
|
||||
.laboratory-result-summary h2,
|
||||
.laboratory-result-summary p,
|
||||
.laboratory-visual-result h2,
|
||||
.laboratory-visual-result p {
|
||||
.laboratory-result-summary p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.laboratory-task h2,
|
||||
.laboratory-result-summary h2,
|
||||
.laboratory-visual-result h2 {
|
||||
.laboratory-result-summary h2 {
|
||||
margin-top: 0.3rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1rem;
|
||||
@@ -2518,8 +2496,7 @@
|
||||
}
|
||||
|
||||
.laboratory-task p,
|
||||
.laboratory-result-summary > p,
|
||||
.laboratory-visual-result header p {
|
||||
.laboratory-result-summary > p {
|
||||
max-width: 66rem;
|
||||
margin-top: 0.38rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
@@ -2566,7 +2543,7 @@
|
||||
}
|
||||
|
||||
.lab-result-surface {
|
||||
min-height: 36rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.lab-result-surface .spatial-workspace {
|
||||
@@ -2575,12 +2552,12 @@
|
||||
|
||||
.laboratory-result-pending {
|
||||
display: grid;
|
||||
min-height: 34rem;
|
||||
min-height: 10rem;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 1rem;
|
||||
background: #06070a;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
color: var(--nodedc-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -2596,123 +2573,6 @@
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.laboratory-visual-result {
|
||||
display: grid;
|
||||
min-height: 34rem;
|
||||
align-content: start;
|
||||
gap: 1rem;
|
||||
background: #08090c;
|
||||
}
|
||||
|
||||
.laboratory-e29-chart {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
border-radius: 0.9rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-e29-chart__bar {
|
||||
display: flex;
|
||||
height: 1.1rem;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
}
|
||||
|
||||
.laboratory-e29-chart__bar i[data-status="agree"],
|
||||
.laboratory-e29-chart__legend i[data-status="agree"] {
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
}
|
||||
|
||||
.laboratory-e29-chart__bar i[data-status="camera"],
|
||||
.laboratory-e29-chart__legend i[data-status="camera"] {
|
||||
background: rgb(var(--nodedc-accent-rgb));
|
||||
}
|
||||
|
||||
.laboratory-e29-chart__bar i[data-status="conflict"],
|
||||
.laboratory-e29-chart__legend i[data-status="conflict"] {
|
||||
min-width: 0.25rem;
|
||||
background: rgb(var(--nodedc-warning-rgb));
|
||||
}
|
||||
|
||||
.laboratory-e29-chart__legend {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.laboratory-e29-chart__legend > div {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.25rem 0.45rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 0.7rem;
|
||||
}
|
||||
|
||||
.laboratory-e29-chart__legend i {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.laboratory-e29-chart__legend span,
|
||||
.laboratory-e29-chart__legend small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.56rem;
|
||||
}
|
||||
|
||||
.laboratory-e29-chart__legend strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.laboratory-e29-chart__legend small {
|
||||
grid-column: 2 / 4;
|
||||
}
|
||||
|
||||
.laboratory-e29-evidence {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.laboratory-e29-evidence article {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.45rem;
|
||||
border-radius: 0.9rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-e29-evidence strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.laboratory-e29-evidence p {
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.62rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.laboratory-e29-evidence article > div {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.laboratory-e29-evidence article > div span {
|
||||
border-radius: 0.6rem;
|
||||
background: rgb(255 255 255 / 0.03);
|
||||
color: var(--nodedc-text-secondary);
|
||||
padding: 0.45rem 0.55rem;
|
||||
font-size: 0.58rem;
|
||||
}
|
||||
|
||||
.laboratory-result-summary {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Button,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
Select,
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
@@ -1218,18 +1219,21 @@ function LaboratorySelector<T extends string>({
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<label>
|
||||
<div className="laboratory-selector__control">
|
||||
<span>{label}</span>
|
||||
<select
|
||||
<Select
|
||||
label={`Выбрать: ${label}`}
|
||||
value={value}
|
||||
options={options.map((option) => ({
|
||||
value: option.id,
|
||||
label: option.label,
|
||||
}))}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.currentTarget.value as T)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.id} value={option.id}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
onChange={(next) => onChange(next)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1268,20 +1272,6 @@ function LaboratoryTask({
|
||||
}
|
||||
|
||||
function E29LaboratoryResult({ rigLabel }: { rigLabel: string }) {
|
||||
const semanticTotal = 19_625;
|
||||
const statuses = [
|
||||
{ id: "agree", label: "Камера + геометрия", value: 6_341 },
|
||||
{ id: "camera", label: "Только камера", value: 13_246 },
|
||||
{ id: "conflict", label: "Конфликт", value: 38 },
|
||||
] as const;
|
||||
const conflictEpisodes = [
|
||||
"track 115 · 69,799–71,398 с",
|
||||
"track 470 · 215,324–218,708 с",
|
||||
"track 679 · 282,569–283,281 с",
|
||||
"track 1011 · 354,937–355,242 с",
|
||||
"track 1276 · 402,606–402,995 с",
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<LaboratoryTask
|
||||
@@ -1296,61 +1286,6 @@ function E29LaboratoryResult({ rigLabel }: { rigLabel: string }) {
|
||||
]}
|
||||
/>
|
||||
|
||||
<section className="laboratory-visual-result" aria-label="Визуальный результат LAB E29">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ РЕЗУЛЬТАТ</span>
|
||||
<h2>Покрытие семантических наблюдений геометрией</h2>
|
||||
<p>
|
||||
Это покрытие одного воспроизводимого replay, а не accuracy и не допуск
|
||||
планировщика. Конфликты сохранены для ручного покадрового разбора.
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge tone="warning">16 конфликтных эпизодов</StatusBadge>
|
||||
</header>
|
||||
|
||||
<div className="laboratory-e29-chart" role="img" aria-label="Распределение статусов геометрии">
|
||||
<div className="laboratory-e29-chart__bar">
|
||||
{statuses.map((status) => (
|
||||
<i
|
||||
key={status.id}
|
||||
data-status={status.id}
|
||||
style={{ width: `${(status.value / semanticTotal) * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="laboratory-e29-chart__legend">
|
||||
{statuses.map((status) => (
|
||||
<div key={status.id}>
|
||||
<i data-status={status.id} />
|
||||
<span>{status.label}</span>
|
||||
<strong>{status.value.toLocaleString("ru-RU")}</strong>
|
||||
<small>{((status.value / semanticTotal) * 100).toLocaleString("ru-RU", {
|
||||
maximumFractionDigits: 2,
|
||||
})}%</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="laboratory-e29-evidence">
|
||||
<article>
|
||||
<span className="section-eyebrow">НЕЗАВИСИМАЯ ГЕОМЕТРИЯ</span>
|
||||
<strong>21 321 компонент</strong>
|
||||
<p>
|
||||
Незасемантизированные занятые компоненты сохранены отдельным слоем:
|
||||
им не назначается выдуманный класс и они не считаются свободным местом.
|
||||
</p>
|
||||
</article>
|
||||
<article>
|
||||
<span className="section-eyebrow">КОНФЛИКТЫ ДЛЯ РАЗБОРА</span>
|
||||
<div>
|
||||
{conflictEpisodes.map((episode) => <span key={episode}>{episode}</span>)}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="laboratory-result-summary">
|
||||
<header>
|
||||
<div>
|
||||
@@ -1575,7 +1510,7 @@ function LabArchiveWorkspace(props: WorkspaceRendererProps) {
|
||||
<LaboratorySelector
|
||||
eyebrow="ЛАБОРАТОРНАЯ РАБОТА"
|
||||
title={workOptions.find((work) => work.id === workId)?.label ?? "Работа не выбрана"}
|
||||
description="Выберите один зафиксированный эксперимент. Ниже откроются его задача, полноразмерное визуальное доказательство и структурированный результат."
|
||||
description="Выберите один зафиксированный эксперимент. Ниже откроются его задача и структурированный результат; viewer появляется только у опубликованного серверного доказательства."
|
||||
label="Работа"
|
||||
value={workId}
|
||||
options={workOptions}
|
||||
|
||||
Reference in New Issue
Block a user