fix(ui): адаптировать шапку страницы к узким экранам

This commit is contained in:
Denozordec committed 2026-09-20 11:49:44 +07:00
1 parent 6a726b13b4
commit 9b3196363c
4 files changed
+120 -14

No files matched your search

+15 -13
View File
@@ -1,6 +1,6 @@
"use client"
import type { ReactNode } from "react"
import { Fragment, type ReactNode } from "react"
import { Separator } from "@/components/ui/separator"
import { SidebarTrigger } from "@/components/ui/sidebar"
import {
@@ -27,31 +27,33 @@ interface PageHeaderProps {
/** Header chrome — AppsMenu + SystemMonitor; theme in NavUser. @see https://reui.io/preview/base/app-shell-12 */
export function PageHeader({ crumbs, actions }: PageHeaderProps) {
return (
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
<header className="bg-background sticky top-0 z-10 flex min-h-12 shrink-0 flex-wrap items-center gap-2 border-b px-4 py-1.5 md:px-6">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb>
<BreadcrumbList>
<Breadcrumb aria-label="Путь страницы" className="min-w-0 flex-1">
<BreadcrumbList className="flex-nowrap">
{crumbs.map((crumb, i) => (
<span key={i} className="flex items-center gap-1.5">
{i > 0 && <BreadcrumbSeparator />}
<BreadcrumbItem>
<Fragment key={i}>
{i > 0 && <BreadcrumbSeparator className="hidden shrink-0 sm:block" />}
<BreadcrumbItem className={i < crumbs.length - 1 ? "hidden shrink-0 sm:inline-flex" : "min-w-0"}>
{i < crumbs.length - 1 && crumb.href ? (
<BreadcrumbLink href={crumb.href}>{crumb.label}</BreadcrumbLink>
) : i < crumbs.length - 1 ? (
<span className="text-muted-foreground text-sm">{crumb.label}</span>
) : (
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
<BreadcrumbPage className="wrap-anywhere">{crumb.label}</BreadcrumbPage>
)}
</BreadcrumbItem>
</span>
</Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex items-center gap-2">
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
<AppsMenu />
<SystemMonitorPopover />
<div className={`ml-auto flex max-w-full items-center gap-2 ${actions ? "w-full xl:w-auto" : "shrink-0"}`}>
{actions ? <div role="group" aria-label="Действия страницы" className="flex min-w-0 flex-1 flex-wrap items-center gap-2">{actions}</div> : null}
<div className="ml-auto flex shrink-0 items-center gap-2">
<AppsMenu />
<SystemMonitorPopover />
</div>
</div>
</header>
)
+10 -1
View File
@@ -20,7 +20,7 @@
| 3 | Основные таблицы и справочники | Завершён 2026-09-13, границы проверок ниже |
| 4 | Остальные таблицы, удаление старых API, общая регрессия | Завершён 2026-09-20; границы приёмки ниже |
| 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Завершён 2026-09-20 для дашборда; границы проверок ниже |
| 6 | Навигация и дашборд | Не начат |
| 6 | Навигация и дашборд | В работе: адаптивная шапка готова, далее навигация и очередь проблем |
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Не начат |
| 8 | Трафик и статистика | Не начат |
| 9 | VPN, Firewall и Filters | Не начат |
@@ -381,6 +381,14 @@ Internet Path использует успешный серверный сним
Квота: 41% пятичасовой на старте, 73% после профильных проверок (недельная 11%). Следующий этап 6 — общая оболочка и дашборд: показать одну схему ПК/телефона по App Shell 12/Stats 12, затем улучшать навигацию, контекст и очередь проблем с переходами к объектам. Этот этап сохранить отдельным локальным коммитом без push; настройки skills/AGENTS/.codex не включать.
## Этап 6 — блок 1: схема и адаптивная шапка (2026-09-20)
Схема ПК/телефона и таблица референсов сохранены в `docs/ux-ui-stage-6.md`. PageHeader сохраняет установленные SidebarTrigger/Breadcrumb/AppsMenu/SystemMonitorPopover: текущая страница видна на телефоне, действия переносятся, фиксированная высота снята. У Breadcrumb корректные прямые li внутри ol и русское доступное имя. Примитивы ReUI, зависимости и backend не менялись.
Проверки: TypeScript и scoped ESLint успешны. Четыре браузерных сценария шапки (320/390/1024/1440 px) прошли: геометрия заголовка/кнопок, меню с клавиатуры, Escape и возврат фокуса. Дополнительно прошли таблица рекурсивных маршрутов и мобильный сценарий серверов. Просмотрены скриншоты 320/1440. Первый запуск теста ошибочно искал banner: header внутри main этой роли не имеет; локатор исправлен без изменения семантики приложения. ReUI validate_usage/checklist получены ранее, используются установленные API и семантические цвета.
Предыдущая попытка запуска была остановлена ошибкой автоматической проверки разрешений из-за исчерпанной квоты; после сброса проверка запуска прошла. На старте этого продолжения использовано 2% пятичасовой и 16% недельной квоты. Этап 6 остаётся открытым. Следующий блок — единый список Sidebar/CommandPalette и стандартное управление фокусом поиска, затем дашборд и ссылки очереди. Локальный коммит без push; файлы настройки другой работы не включать.
## Карта миграции (исходная инвентаризация этапа 0)
Исходная связка на этапе 0: @tanstack/react-table ^8.21.3, @tanstack/react-virtual ^3.14.4, base-nova. По итогам этапа 4 используется @tanstack/react-table 9.2.4 с useTable/dataGridFeatures и дерево FilterQuery вместо старого filters/onChange. Список ниже сохранён как историческая инвентаризация.
@@ -445,3 +453,4 @@ Internet Path использует успешный серверный сним
- `app/(main)/statistics/page.tsx`
- `components/backups/backups-history.tsx`
- `components/data-page-toolbar.tsx`
+44
View File
@@ -0,0 +1,44 @@
# Этап 6: общая оболочка и дашборд
Рабочая схема от 2026-09-20. Основа — ui-design-contract.md: ReUI PRO, base-nova, Frame, Sidebar 240 px. Поиск вызывается Ctrl+K/⌘K, тема остаётся в NavUser. Весь этап ещё не завершён.
| Область | Референс | Результат |
| --- | --- | --- |
| Оболочка | [App Shell 12](https://reui.io/preview/base/app-shell-12?ref=mcp) | Контекст страницы, доступные действия, меню и поиск с клавиатуры |
| Сводка | [Stats 12](https://reui.io/preview/base/stats-12?ref=mcp) | KpiStatGrid/IconTile во Frame, предметные подписи, неизвестное значение — «—» |
| Очередь проблем | [List 9](https://reui.io/preview/base/list-9?ref=mcp) | Проблема → объект → доступное действие |
## ПК
```text
┌ Навигация 240 px ┬ Путь страницы · Действия · Приложения · Монитор ┐
│ Обзор ├ Свежесть / ошибки источников / обновить │
│ Данные ├ Сводка: серверы · туннели · BGP · проверки │
│ Управление ├ Очередь проблем → сервер / профильный раздел │
│ Инструменты ├ Измерения и события │
│ Система │ Быстрые переходы │
└─────────────────┴─────────────────────────────────────────────────┘
```
## Телефон
```text
┌ Меню · Текущая страница ┐
│ Действия с переносом · Приложения · Монитор │
├ Свежесть / ошибки источников ┤
│ Сводка в адаптивной сетке │
│ Очередь проблем → объект │
│ Измерения → события → быстрые переходы │
└────────────────────────────────────────────┘
```
На страницах без действий служебные кнопки остаются рядом с заголовком. При наличии действий и ширине менее 1280 px выделяется дополнительная строка с переносом кнопок. DOM-порядок совпадает с визуальным и клавиатурным, текущий заголовок не скрывается многоточием. На ПК сохраняется компактная строка, при нехватке места шапка растёт по высоте.
## Блоки и приёмка
1. **Шапка.** Убрать фиксированную высоту и обрезку; текущая страница видна на телефоне; корректный список Breadcrumb. Проверить 320/390/1024/1440 px, клавиатуру и Escape.
2. **Навигация.** Общий источник названий, адресов и поисковых синонимов для Sidebar и CommandPalette. Использовать установленные Command/Dialog для фокуса, поиска, клавиатуры и семантических цветов. Закрывать мобильное меню после перехода. Сверить маршруты с существующими страницами.
3. **Дашборд.** Уточнить подписи «Флот», «Оверлей», «BGP Rx», «Здоровье». Поднять очередь проблем после сводки, дать контекст и переходы к объектам. Сохранить состояния источников этапа 5; отсутствие данных не считать отсутствием проблем. Быстрые переходы не должны вытеснять проблемы за первый экран.
4. **Приёмка.** Просмотреть ПК/телефон, проверить переходы, пустое состояние, частичный сбой и клавиатуру; профильные тесты, TypeScript, lint и production build общей оболочки.
HTTP-моки проверяют интерфейс и запросы. Реальные данные, права и RouterOS требуют тестового окружения. Формы и полный URL-контекст сервера относятся к этапу 7; сетевые операции в этапе 6 не менять.
+51
View File
@@ -0,0 +1,51 @@
import { test, expect } from "@playwright/test"
for (const width of [320, 390, 1024, 1440]) {
test(`шапка ${width}px: заголовок, действия и меню доступны без обрезки`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 })
const errors: string[] = []
page.on("pageerror", error => errors.push(error.message))
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
await page.route("**/api/**", route => {
const path = new URL(route.request().url()).pathname
return route.fulfill({ json: path === "/api/recursive-routes" ? { routes: [] }
: path === "/api/recursive-routes/gateways" ? { gateways: [] }
: path === "/api/auth/config" ? { required: false }
: path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.100", type: "exit-node", site: "TEST", country: "RU", asn: "64512", enabled: true, status: "online", latency: 1 }]
: path === "/api/evobgp/settings" ? { enabled: false, baseUrl: "", secretConfigured: false } : {} })
})
await page.goto("/recursive-routes")
const header = page.locator("header").filter({ has: page.getByRole("navigation", { name: "Путь страницы" }) })
const current = header.locator('[aria-current="page"]')
await expect(current).toHaveText("Рекурсивные маршруты")
await expect(header.getByRole("button", { name: "Обновить", exact: true })).toBeEnabled()
// Check the actual visible controls, not just document scrollWidth: a clipped
// child in an overflow-hidden application shell can otherwise pass unnoticed.
const visibleBounds = await header.evaluate(element => {
const parent = element.getBoundingClientRect()
return [...element.querySelectorAll('button, [aria-current="page"]')]
.filter(child => child.getClientRects().length > 0)
.map(child => {
const rect = child.getBoundingClientRect()
return { label: child.textContent, inside: rect.left >= parent.left && rect.right <= Math.min(parent.right, window.innerWidth) + 1 && rect.top >= parent.top && rect.bottom <= parent.bottom + 1 }
})
})
expect(visibleBounds.filter(control => !control.inside)).toEqual([])
expect(await header.locator("ol").evaluate(list => [...list.children].every(child => child.tagName === "LI"))).toBe(true)
const apps = header.getByRole("button", { name: "Приложения", exact: true })
await apps.focus()
await page.keyboard.press("Enter")
await expect(page.getByRole("menu")).toBeVisible()
await page.keyboard.press("Escape")
await expect(apps).toBeFocused()
if (width < 768) {
const sidebar = header.getByRole("button", { name: "Боковое меню", exact: true })
await sidebar.press("Enter")
await expect(page.getByRole("dialog")).toBeVisible()
await page.keyboard.press("Escape")
await expect(sidebar).toBeFocused()
}
await page.screenshot({ path: `test-results/page-header-${width}.png`, fullPage: true, animations: "disabled" })
expect(errors).toEqual([])
})
}