fix(device-manager): align shell with NODE.DC UX canon

This commit is contained in:
Codex
2026-08-13 11:51:08 +03:00
parent 2fa1951f51
commit 8f38c76f79
4 changed files with 108 additions and 145 deletions
@@ -0,0 +1 @@
<svg id="nodedc-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220.82 54.55"><defs><style>.cls-1{fill:#e2e1e1;}.cls-2{fill:#dbdbdb;stroke:#dbdbdb;stroke-miterlimit:10;stroke-width:0.75px;}</style></defs><path class="cls-1" d="M52.8,23.61,46.92,33.76,41.05,23.61H52.8m18-10.39H23.06L46.92,54.55Z"/><polygon class="cls-1" points="31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13"/><path class="cls-2" d="M116.35,18.49V1h1.27l10.34,15V1h1.33V18.49H128l-10.34-15v15Z"/><path class="cls-2" d="M140.43,18.64c-4.79,0-8.16-3.72-8.16-8.89S135.64.86,140.43.86s8.17,3.72,8.17,8.89S145.25,18.64,140.43,18.64Zm0-1.25c4,0,6.79-3.17,6.79-7.64s-2.77-7.64-6.79-7.64-6.77,3.17-6.77,7.64S136.44,17.39,140.43,17.39Z"/><path class="cls-2" d="M151.6,18.49V1h5.1c5.54,0,8.79,3.42,8.79,8.74s-3.25,8.74-8.79,8.74ZM153,17.24h3.75c4.77,0,7.42-2.92,7.42-7.49s-2.65-7.49-7.42-7.49H153Z"/><path class="cls-2" d="M168.49,1h10.77V2.26h-9.42V8.93h7.89v1.25h-7.89v7.06h9.74v1.25H168.49Z"/><path class="cls-2" d="M188.88,18.49V1H194c5.54,0,8.79,3.42,8.79,8.74s-3.25,8.74-8.79,8.74Zm1.35-1.25H194c4.77,0,7.41-2.92,7.41-7.49S198.75,2.26,194,2.26h-3.75Z"/><path class="cls-2" d="M205.15,9.75c0-5.24,3.19-8.89,8.11-8.89a6.8,6.8,0,0,1,7.1,5.52h-1.43a5.54,5.54,0,0,0-5.74-4.27c-4.05,0-6.64,3.17-6.64,7.64s2.54,7.64,6.59,7.64a5.46,5.46,0,0,0,5.74-4.29h1.43c-.75,3.52-3.4,5.54-7.15,5.54C208.27,18.64,205.15,15.05,205.15,9.75Z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1 @@
<svg id="nodedc-mark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 93.22 54.55"><path fill="#e2e1e1" d="M52.8 23.61 46.92 33.76 41.05 23.61H52.8m18-10.39H23.06l23.86 41.33Z"/><polygon fill="#e2e1e1" points="31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13"/></svg>

After

Width:  |  Height:  |  Size: 315 B

+76 -96
View File
@@ -6,10 +6,10 @@ import {
ApplicationShell, ApplicationShell,
Button, Button,
GlassSurface, GlassSurface,
HeaderNavigation,
HeaderProfile, HeaderProfile,
HeaderWorkspace, HeaderWorkspace,
Icon, Icon,
IconButton,
Select, Select,
SettingsCard, SettingsCard,
StatusBadge, StatusBadge,
@@ -129,10 +129,9 @@ export function DeviceManagerApp() {
[activeOwnerRef, projects], [activeOwnerRef, projects],
); );
const activeProject = projects.find((project) => project.projectRef === activeProjectRef) ?? null; const activeProject = projects.find((project) => project.projectRef === activeProjectRef) ?? null;
const creatableOwnerScopes = session?.actor.ownerScopes ?? [];
const capabilities = new Set(activeProject?.access.capabilities ?? []); const capabilities = new Set(activeProject?.access.capabilities ?? []);
const canCreateProject = Boolean( const canCreateProject = creatableOwnerScopes.length > 0;
session?.actor.ownerScopes.some((scope) => scope.ownerRef === activeOwnerRef),
);
const canManageCollections = capabilities.has("collection.manage"); const canManageCollections = capabilities.has("collection.manage");
const canEnroll = capabilities.has("device.enroll"); const canEnroll = capabilities.has("device.enroll");
const canClaim = capabilities.has("device.claim"); const canClaim = capabilities.has("device.claim");
@@ -182,26 +181,18 @@ export function DeviceManagerApp() {
<ApplicationShell <ApplicationShell
header={ header={
<AppHeader <AppHeader
brand={<span className="device-manager-brand">NODE.DC</span>} brand={<img className="device-manager-brand" src="/nodedc-logo.svg" alt="NODE.DC" />}
brandHref="/" brandHref="/"
left={ center={
<button <button
className="device-manager-workspace-trigger" className="device-manager-workspace-trigger"
type="button" type="button"
aria-label="Открыть Device Core" aria-label="Открыть навигацию Device Core"
onClick={shell.toggleNavigation} onClick={shell.toggleNavigation}
> >
<HeaderWorkspace label="Device Core" /> <HeaderWorkspace kind="mark" label="Device Core" imageUrl="/nodedc-mark.svg" />
</button> </button>
} }
center={
<HeaderNavigation
label="Device Core"
value="devices"
items={[{ value: "devices", label: "Device Core" }]}
onChange={() => shell.openNavigation()}
/>
}
right={ right={
<HeaderProfile> <HeaderProfile>
<UserProfileMenu <UserProfileMenu
@@ -219,12 +210,7 @@ export function DeviceManagerApp() {
} }
stage={ stage={
<DeviceStage <DeviceStage
ownerScopes={ownerScopes}
activeOwnerRef={activeOwnerRef}
onOwnerChange={setActiveOwnerRef}
projects={visibleProjects} projects={visibleProjects}
canCreateProject={canCreateProject}
onCreateProject={() => setProjectDialogOpen(true)}
onOpenProject={openProject} onOpenProject={openProject}
error={error} error={error}
onDismissError={() => setError(null)} onDismissError={() => setError(null)}
@@ -234,27 +220,44 @@ export function DeviceManagerApp() {
navigation={ navigation={
<AdminNavigationPanel <AdminNavigationPanel
eyebrow="DEVICE CORE" eyebrow="DEVICE CORE"
title="Устройства" title="Проекты"
contexts={activeProject ? [{ headerActions={
id: activeProject.projectRef, <IconButton
label: activeProject.name, label="Новый проект"
description: activeProject.ownerScope.displayName, disabled={!canCreateProject}
active: true, onClick={() => setProjectDialogOpen(true)}
icon: <Icon name="apps" />, >
}] : []} <Icon name="plus" size={16} />
</IconButton>
}
contextSlot={ contextSlot={
ownerScopes.length ? ( ownerScopes.length ? (
<Select <div className="device-manager-navigation-context">
value={activeOwnerRef} <Select
label="Контур владельца" value={activeOwnerRef}
options={ownerScopes.map((scope) => ({ label="Контур владельца"
value: scope.ownerRef, options={ownerScopes.map((scope) => ({
label: scope.displayName, value: scope.ownerRef,
description: scope.scopeKind === "company" ? "Компания" : "Личный контур", label: scope.displayName,
}))} description: scope.scopeKind === "company" ? "Компания" : "Личный контур",
onChange={setActiveOwnerRef} }))}
searchable={ownerScopes.length > 6} onChange={setActiveOwnerRef}
/> searchable={ownerScopes.length > 6}
/>
{visibleProjects.length ? (
<Select
value={activeProjectRef}
label="Проект"
options={visibleProjects.map((project) => ({
value: project.projectRef,
label: project.name,
description: project.access.projectRole || "read",
}))}
onChange={(projectRef) => openProject(projectRef)}
searchable={visibleProjects.length > 6}
/>
) : null}
</div>
) : null ) : null
} }
items={activeProject ? visibleNavigationItems.map((item) => ({ items={activeProject ? visibleNavigationItems.map((item) => ({
@@ -262,7 +265,7 @@ export function DeviceManagerApp() {
label: item.label, label: item.label,
icon: <Icon name={item.icon} />, icon: <Icon name={item.icon} />,
})) : []} })) : []}
activeId={shell.activeView ?? undefined} activeId={activeProject ? activeView : undefined}
footer={<span>{session.actor.hubRole} · {projects.length} проектов</span>} footer={<span>{session.actor.hubRole} · {projects.length} проектов</span>}
onClose={shell.closeNavigation} onClose={shell.closeNavigation}
onItemChange={(id) => shell.openView(id as ViewId)} onItemChange={(id) => shell.openView(id as ViewId)}
@@ -306,7 +309,8 @@ export function DeviceManagerApp() {
<ProjectDialog <ProjectDialog
open={projectDialogOpen} open={projectDialogOpen}
ownerScope={ownerScopes.find((scope) => scope.ownerRef === activeOwnerRef) ?? null} ownerScopes={creatableOwnerScopes}
initialOwnerRef={activeOwnerRef}
onClose={() => setProjectDialogOpen(false)} onClose={() => setProjectDialogOpen(false)}
onCreated={async () => { onCreated={async () => {
setProjectDialogOpen(false); setProjectDialogOpen(false);
@@ -349,61 +353,18 @@ export function DeviceManagerApp() {
} }
function DeviceStage({ function DeviceStage({
ownerScopes,
activeOwnerRef,
onOwnerChange,
projects, projects,
canCreateProject,
onCreateProject,
onOpenProject, onOpenProject,
error, error,
onDismissError, onDismissError,
}: { }: {
ownerScopes: OwnerScopeClaim[];
activeOwnerRef: string;
onOwnerChange: (value: string) => void;
projects: ProjectSummary[]; projects: ProjectSummary[];
canCreateProject: boolean;
onCreateProject: () => void;
onOpenProject: (projectRef: string, view?: ViewId) => void; onOpenProject: (projectRef: string, view?: ViewId) => void;
error: string | null; error: string | null;
onDismissError: () => void; onDismissError: () => void;
}) { }) {
const totals = projects.reduce(
(sum, project) => ({
devices: sum.devices + project.counts.devices,
collections: sum.collections + project.counts.collections,
discoveries: sum.discoveries + project.counts.discoveries,
}),
{ devices: 0, collections: 0, discoveries: 0 },
);
return ( return (
<div className="device-manager-stage"> <div className="device-manager-stage">
<section className="device-manager-hero">
<div>
<span>UNIVERSAL DEVICE PLANE</span>
<h1>Все устройства в одном управляемом контуре.</h1>
<p>Проекты, безопасное подключение, инвентарь и коллекции без привязки интерфейса к конкретному производителю.</p>
</div>
<div className="device-manager-hero__actions">
{ownerScopes.length ? (
<Select
value={activeOwnerRef}
label="Контур владельца"
options={ownerScopes.map((scope) => ({
value: scope.ownerRef,
label: scope.displayName,
description: scope.scopeKind === "company" ? "Компания" : "Личный контур",
}))}
onChange={onOwnerChange}
/>
) : null}
<Button variant="primary" icon={<Icon name="plus" />} disabled={!canCreateProject} onClick={onCreateProject}>
Новый проект
</Button>
</div>
</section>
{error ? ( {error ? (
<div className="device-manager-alert" role="alert"> <div className="device-manager-alert" role="alert">
<Icon name="alert" /> <Icon name="alert" />
@@ -412,14 +373,13 @@ function DeviceStage({
</div> </div>
) : null} ) : null}
<section className="device-manager-metrics" aria-label="Состояние контура"> <GlassSurface className="device-manager-home" padding="lg" tone="soft">
<Metric label="Проекты" value={projects.length} detail="доступно пользователю" /> <section className="device-manager-hero">
<Metric label="Устройства" value={totals.devices} detail="в выбранном контуре" /> <span>NODEDC / DEVICE CORE</span>
<Metric label="Подключение" value={totals.discoveries} detail="ожидают решения" tone={totals.discoveries ? "warning" : "success"} /> <h1>Device Core</h1>
<Metric label="Коллекции" value={totals.collections} detail="логических групп" /> <p>Единый контур подключения, учёта и управления устройствами.</p>
</section> </section>
<section className="device-manager-projects">
<section className="device-manager-projects">
<div className="device-manager-section-title"> <div className="device-manager-section-title">
<div><span>DEVICE PROJECTS</span><h2>Рабочие проекты</h2></div> <div><span>DEVICE PROJECTS</span><h2>Рабочие проекты</h2></div>
<small>Доступ определяется Hub и project grants</small> <small>Доступ определяется Hub и project grants</small>
@@ -451,7 +411,8 @@ function DeviceStage({
<p>Создание доступно только в owner scope, подтверждённом Hub.</p> <p>Создание доступно только в owner scope, подтверждённом Hub.</p>
</GlassSurface> </GlassSurface>
)} )}
</section> </section>
</GlassSurface>
</div> </div>
); );
} }
@@ -573,9 +534,10 @@ function EntityList({ items, empty }: { items: Array<{ id: string; title: string
))}</div>; ))}</div>;
} }
function ProjectDialog({ open, ownerScope, onClose, onCreated, onError }: { function ProjectDialog({ open, ownerScopes, initialOwnerRef, onClose, onCreated, onError }: {
open: boolean; open: boolean;
ownerScope: OwnerScopeClaim | null; ownerScopes: OwnerScopeClaim[];
initialOwnerRef: string;
onClose: () => void; onClose: () => void;
onCreated: () => Promise<void>; onCreated: () => Promise<void>;
onError: (reason: unknown) => void; onError: (reason: unknown) => void;
@@ -583,7 +545,15 @@ function ProjectDialog({ open, ownerScope, onClose, onCreated, onError }: {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [key, setKey] = useState(""); const [key, setKey] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [ownerRef, setOwnerRef] = useState(initialOwnerRef);
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
useEffect(() => {
if (!open) return;
setOwnerRef(ownerScopes.some((scope) => scope.ownerRef === initialOwnerRef)
? initialOwnerRef
: ownerScopes[0]?.ownerRef ?? "");
}, [initialOwnerRef, open, ownerScopes]);
const ownerScope = ownerScopes.find((scope) => scope.ownerRef === ownerRef) ?? null;
const submit = async (event: FormEvent) => { const submit = async (event: FormEvent) => {
event.preventDefault(); event.preventDefault();
if (!ownerScope) return; if (!ownerScope) return;
@@ -605,6 +575,16 @@ function ProjectDialog({ open, ownerScope, onClose, onCreated, onError }: {
<WindowFooterActions><Button variant="ghost" onClick={onClose}>Отмена</Button><Button type="submit" form="device-project-form" variant="primary" disabled={pending || !ownerScope}>{pending ? "Создаём…" : "Создать"}</Button></WindowFooterActions> <WindowFooterActions><Button variant="ghost" onClick={onClose}>Отмена</Button><Button type="submit" form="device-project-form" variant="primary" disabled={pending || !ownerScope}>{pending ? "Создаём…" : "Создать"}</Button></WindowFooterActions>
}> }>
<form id="device-project-form" className="device-manager-form" onSubmit={submit}> <form id="device-project-form" className="device-manager-form" onSubmit={submit}>
<Select
label="Контур проекта"
value={ownerRef}
options={ownerScopes.map((scope) => ({
value: scope.ownerRef,
label: scope.displayName,
description: scope.scopeKind === "company" ? "Коммерческий контур" : "Личный контур",
}))}
onChange={setOwnerRef}
/>
<TextField label="Название" value={name} onChange={(event) => setName(event.target.value)} required maxLength={160} /> <TextField label="Название" value={name} onChange={(event) => setName(event.target.value)} required maxLength={160} />
<TextField label="Ключ проекта" value={key} onChange={(event) => setKey(event.target.value.toLowerCase())} required pattern="[a-z][a-z0-9-]{1,62}" description="Стабильный ключ: латиница, цифры и дефис." /> <TextField label="Ключ проекта" value={key} onChange={(event) => setKey(event.target.value.toLowerCase())} required pattern="[a-z][a-z0-9-]{1,62}" description="Стабильный ключ: латиница, цифры и дефис." />
<TextAreaField label="Описание" value={description} onChange={(event) => setDescription(event.target.value)} maxLength={2000} /> <TextAreaField label="Описание" value={description} onChange={(event) => setDescription(event.target.value)} maxLength={2000} />
+30 -49
View File
@@ -205,10 +205,9 @@ textarea {
} }
.device-manager-brand { .device-manager-brand {
color: var(--nodedc-text-primary); display: block;
font-size: 0.86rem; width: 124px;
font-weight: 800; height: auto;
letter-spacing: 0.15em;
} }
.device-manager-workspace-trigger { .device-manager-workspace-trigger {
@@ -222,24 +221,26 @@ textarea {
.device-manager-stage { .device-manager-stage {
height: 100%; height: 100%;
overflow: auto; overflow: auto;
padding: clamp(2.25rem, 5vw, 5.5rem); padding: clamp(1rem, 2.5vw, 2.5rem);
scrollbar-width: thin; scrollbar-width: thin;
} }
.device-manager-hero { .device-manager-home {
display: flex; display: flex;
min-height: calc(100% - 1px);
max-width: 1420px; max-width: 1420px;
align-items: end; flex-direction: column;
justify-content: space-between; justify-content: center;
gap: 2rem; gap: clamp(3rem, 7vh, 6rem);
margin: 0 auto 2.5rem; margin: 0 auto;
padding: clamp(2.5rem, 6vw, 6rem) !important;
} }
.device-manager-hero > div:first-child { .device-manager-hero {
max-width: 780px; max-width: 900px;
} }
.device-manager-hero span, .device-manager-hero > span,
.device-manager-section-title span { .device-manager-section-title span {
color: rgb(var(--nodedc-accent-rgb)); color: rgb(var(--nodedc-accent-rgb));
font-size: 0.68rem; font-size: 0.68rem;
@@ -248,13 +249,12 @@ textarea {
} }
.device-manager-hero h1 { .device-manager-hero h1 {
max-width: 760px;
margin: 0.7rem 0 0.9rem; margin: 0.7rem 0 0.9rem;
color: var(--nodedc-text-primary); color: var(--nodedc-text-primary);
font-size: clamp(2.25rem, 5vw, 5.4rem); font-size: clamp(4rem, 8vw, 8.5rem);
font-weight: 520; font-weight: 520;
letter-spacing: -0.065em; letter-spacing: -0.065em;
line-height: 0.98; line-height: 0.9;
} }
.device-manager-hero p { .device-manager-hero p {
@@ -265,18 +265,6 @@ textarea {
line-height: 1.55; line-height: 1.55;
} }
.device-manager-hero__actions {
display: flex;
min-width: min(100%, 390px);
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
}
.device-manager-hero__actions .nodedc-select-anchor {
min-width: 210px;
}
.device-manager-alert { .device-manager-alert {
display: flex; display: flex;
max-width: 1420px; max-width: 1420px;
@@ -325,8 +313,12 @@ textarea {
} }
.device-manager-projects { .device-manager-projects {
max-width: 1420px; width: 100%;
margin: 0 auto; }
.device-manager-navigation-context {
display: grid;
gap: 0.65rem;
} }
.device-manager-section-title { .device-manager-section-title {
@@ -355,7 +347,7 @@ textarea {
.device-project-card { .device-project-card {
display: grid; display: grid;
min-height: 210px; min-height: 160px;
grid-template-columns: auto 1fr auto; grid-template-columns: auto 1fr auto;
grid-template-rows: 1fr auto; grid-template-rows: 1fr auto;
gap: 1rem; gap: 1rem;
@@ -890,16 +882,6 @@ textarea {
} }
@media (max-width: 1100px) { @media (max-width: 1100px) {
.device-manager-hero {
align-items: start;
flex-direction: column;
}
.device-manager-hero__actions {
width: 100%;
justify-content: flex-start;
}
.device-manager-metrics { .device-manager-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
@@ -909,25 +891,24 @@ textarea {
body { overflow: auto; } body { overflow: auto; }
.device-manager-stage { .device-manager-stage {
padding: 2rem 1rem 4rem; padding: 1rem;
}
.device-manager-home {
min-height: auto;
padding: 2.5rem 1.25rem !important;
} }
.device-manager-hero h1 { .device-manager-hero h1 {
font-size: clamp(2.25rem, 14vw, 3.5rem); font-size: clamp(3.4rem, 17vw, 6rem);
} }
.device-manager-hero__actions,
.device-manager-panel-toolbar, .device-manager-panel-toolbar,
.device-manager-section-title { .device-manager-section-title {
align-items: stretch; align-items: stretch;
flex-direction: column; flex-direction: column;
} }
.device-manager-hero__actions > *,
.device-manager-hero__actions .nodedc-select-anchor {
width: 100%;
}
.device-manager-metrics, .device-manager-metrics,
.device-manager-overview-grid { .device-manager-overview-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;