feat: Обновить компоненты интерфейса, добавив новый заголовок страницы и состояние пустого контента в менеджерах ASNs, Domains и IPRanges, а также улучшить стили для адаптивного отображения
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m0s

This commit is contained in:
2025-08-27 17:16:46 +07:00
parent 001b4d9d98
commit 434462f633
13 changed files with 255 additions and 71 deletions
+22 -13
View File
@@ -17,9 +17,12 @@ import {
IconClock, IconClock,
IconFileText IconFileText
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import TableSkeleton from './components/TableSkeleton.jsx'; import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
import S3MetaBar from './components/S3MetaBar.jsx'; import S3MetaBar from './components/S3MetaBar.jsx';
import PageHeaderActions from './components/PageHeaderActions.jsx'; import PageHeaderActions from './components/PageHeaderActions.jsx';
import PageHeader from './components/PageHeader.jsx';
import EmptyState from './components/EmptyState.jsx';
import Breadcrumbs from './components/Breadcrumbs.jsx';
import ConfirmDialog from './components/ConfirmDialog.jsx'; import ConfirmDialog from './components/ConfirmDialog.jsx';
import WsUpdateModal from './components/WsUpdateModal.jsx'; import WsUpdateModal from './components/WsUpdateModal.jsx';
import ConfirmDiffModal from './components/ConfirmDiffModal.jsx'; import ConfirmDiffModal from './components/ConfirmDiffModal.jsx';
@@ -409,14 +412,17 @@ function ASNsNewManager() {
</div> </div>
)} )}
<div className="page-header d-print-none mb-4"> <PageHeader
<div className="row align-items-center"> title="ASNs"
<div className="col"> pretitle={(
<h2 className="page-title">ASNs</h2> <Breadcrumbs items={[
<div className="page-pretitle">Главная / Данные / ASNs</div> { label: 'Главная', to: '/dashboard' },
</div> { label: 'Данные' },
<div className="col-auto ms-auto d-print-none"> { label: 'ASNs' },
<PageHeaderActions ]} />
)}
actions={(
<PageHeaderActions
loading={loading} loading={loading}
onRefresh={fetchItems} onRefresh={fetchItems}
disableRefresh={loading} disableRefresh={loading}
@@ -443,10 +449,9 @@ function ASNsNewManager() {
window.notify?.error('Не удалось запустить фоновое обновление', String(e)); window.notify?.error('Не удалось запустить фоновое обновление', String(e));
} }
}} }}
/> />
</div> )}
</div> />
</div>
<div className="row"> <div className="row">
<div className="col-lg-3"> <div className="col-lg-3">
{/* Add New ASN Card */} {/* Add New ASN Card */}
@@ -551,6 +556,10 @@ function ASNsNewManager() {
{/* Мета-информация будет показана внизу карточки */} {/* Мета-информация будет показана внизу карточки */}
{loading ? ( {loading ? (
<TableSkeleton rows={10} cols={3} /> <TableSkeleton rows={10} cols={3} />
) : paginatedItems.length === 0 ? (
<TableEmpty cols={3}>
<EmptyState title="Нет ASN" description="Импортируйте или добавьте записи, чтобы начать." action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>} />
</TableEmpty>
) : ( ) : (
<div className="table-responsive"> <div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0"> <table className="table card-table table-vcenter table-nowrap mb-0">
+3 -3
View File
@@ -211,7 +211,7 @@ function MainLayout() {
const activeTab = getActiveTab(); const activeTab = getActiveTab();
return ( return (
<div className="page"> <div className="page d-flex flex-column min-vh-100">
{/* Верхнее меню Tabler Admin */} {/* Верхнее меню Tabler Admin */}
<header className="navbar navbar-expand-md navbar-light d-print-none" style={{boxShadow: '0 1px 0 0 #e9ecef'}}> <header className="navbar navbar-expand-md navbar-light d-print-none" style={{boxShadow: '0 1px 0 0 #e9ecef'}}>
<div className="container-xl"> <div className="container-xl">
@@ -322,7 +322,7 @@ function MainLayout() {
</div> </div>
</div> </div>
</header> </header>
<div className="container-xl mt-4"> <div className="container-xl mt-4 flex-grow-1">
<Routes> <Routes>
<Route path="/dashboard" element={<Dashboard />} /> <Route path="/dashboard" element={<Dashboard />} />
<Route path="/domains" element={<DomainsNewManager />} /> <Route path="/domains" element={<DomainsNewManager />} />
@@ -336,7 +336,7 @@ function MainLayout() {
<Route path="/" element={<Navigate to="/dashboard" replace />} /> <Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes> </Routes>
</div> </div>
<footer className="footer mt-auto py-3"> <footer className="footer mt-auto py-2">
<div className="container-xl text-muted small"> <div className="container-xl text-muted small">
Версия UI: {import.meta?.env?.VITE_APP_VERSION || 'dev'} Версия UI: {import.meta?.env?.VITE_APP_VERSION || 'dev'}
</div> </div>
+21 -13
View File
@@ -16,6 +16,10 @@ import {
IconFilter IconFilter
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import PageHeaderActions from './components/PageHeaderActions.jsx'; import PageHeaderActions from './components/PageHeaderActions.jsx';
import PageHeader from './components/PageHeader.jsx';
import EmptyState from './components/EmptyState.jsx';
import { TableEmpty } from './components/TableSkeleton.jsx';
import Breadcrumbs from './components/Breadcrumbs.jsx';
const API_URL = '/api'; const API_URL = '/api';
@@ -224,14 +228,17 @@ function CommunitiesManager() {
return ( return (
<div> <div>
<div className="page-header d-print-none mb-4"> <PageHeader
<div className="row align-items-center"> title={(<><IconFilter className="me-2" />Справочник Community</>)}
<div className="col"> pretitle={(
<h2 className="page-title"><IconFilter className="me-2" />Справочник Community</h2> <Breadcrumbs items={[
<div className="page-pretitle">Главная / Данные / Community</div> { label: 'Главная', to: '/dashboard' },
</div> { label: 'Данные' },
<div className="col-auto ms-auto d-print-none"> { label: 'Community' },
<PageHeaderActions ]} />
)}
actions={(
<PageHeaderActions
loading={loading} loading={loading}
onRefresh={fetchItems} onRefresh={fetchItems}
disableRefresh={loading} disableRefresh={loading}
@@ -240,10 +247,9 @@ function CommunitiesManager() {
disableExport={items.length === 0} disableExport={items.length === 0}
onSave={handleSave} onSave={handleSave}
disableSave={loading} disableSave={loading}
/> />
</div> )}
</div> />
</div>
{error && ( {error && (
<div className="alert alert-danger alert-dismissible" role="alert"> <div className="alert alert-danger alert-dismissible" role="alert">
@@ -319,7 +325,9 @@ function CommunitiesManager() {
{loading ? ( {loading ? (
<tr><td colSpan={6} className="text-center text-muted py-4">Загрузка...</td></tr> <tr><td colSpan={6} className="text-center text-muted py-4">Загрузка...</td></tr>
) : paginated.length === 0 ? ( ) : paginated.length === 0 ? (
<tr><td colSpan={6} className="text-center text-muted py-4">Нет записей</td></tr> <tr><td colSpan={6} className="text-center text-muted py-4">
<EmptyState title="Нет community" description="Добавьте запись, чтобы начать." action={<button className="btn btn-primary" onClick={addItem}>Добавить</button>} />
</td></tr>
) : paginated.map((item) => ( ) : paginated.map((item) => (
<tr key={item.value} className={editingValue === item.value ? 'table-info' : ''}> <tr key={item.value} className={editingValue === item.value ? 'table-info' : ''}>
<td> <td>
+11 -13
View File
@@ -16,6 +16,8 @@ import {
IconCreditCard, IconCreditCard,
IconSearch IconSearch
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import PageHeader from './components/PageHeader.jsx';
import Breadcrumbs from './components/Breadcrumbs.jsx';
function StatCard({ icon: Icon, color, value, title, subtitle, to }) { function StatCard({ icon: Icon, color, value, title, subtitle, to }) {
return ( return (
@@ -172,19 +174,15 @@ function Dashboard() {
<div> <div>
{/* Глобальный поиск удалён */} {/* Глобальный поиск удалён */}
{/* Заголовок страницы */} {/* Заголовок страницы */}
<div className="page-header d-print-none mb-4"> <PageHeader
<div className="row align-items-center"> title="Панель"
<div className="col"> pretitle={(
<h2 className="page-title">Панель</h2> <Breadcrumbs items={[
<div className="page-pretitle">Краткая сводка по данным</div> { label: 'Главная' },
</div> ]} />
<div className="col-auto ms-auto d-print-none"> )}
<button className="btn btn-outline-primary" onClick={() => window.location.reload()}> actions={(<button className="btn btn-outline-primary" onClick={() => window.location.reload()}><IconRefresh className="me-2" /> Обновить</button>)}
<IconRefresh className="me-2" /> Обновить />
</button>
</div>
</div>
</div>
{/* Основные метрики */} {/* Основные метрики */}
<div className="row g-3 mb-4"> <div className="row g-3 mb-4">
+22 -13
View File
@@ -17,7 +17,10 @@ import {
IconClock, IconClock,
IconFileText IconFileText
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import TableSkeleton from './components/TableSkeleton.jsx'; import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
import PageHeader from './components/PageHeader.jsx';
import EmptyState from './components/EmptyState.jsx';
import Breadcrumbs from './components/Breadcrumbs.jsx';
import S3MetaBar from './components/S3MetaBar.jsx'; import S3MetaBar from './components/S3MetaBar.jsx';
import PageHeaderActions from './components/PageHeaderActions.jsx'; import PageHeaderActions from './components/PageHeaderActions.jsx';
import ConfirmDialog from './components/ConfirmDialog.jsx'; import ConfirmDialog from './components/ConfirmDialog.jsx';
@@ -414,14 +417,17 @@ function DomainsNewManager() {
</div> </div>
)} )}
<div className="page-header d-print-none mb-4"> <PageHeader
<div className="row align-items-center"> title="Домены"
<div className="col"> pretitle={(
<h2 className="page-title">Домены</h2> <Breadcrumbs items={[
<div className="page-pretitle">Главная / Данные / Домены</div> { label: 'Главная', to: '/dashboard' },
</div> { label: 'Данные' },
<div className="col-auto ms-auto d-print-none"> { label: 'Домены' },
<PageHeaderActions ]} />
)}
actions={(
<PageHeaderActions
loading={loading} loading={loading}
onRefresh={fetchItems} onRefresh={fetchItems}
disableRefresh={loading} disableRefresh={loading}
@@ -448,10 +454,9 @@ function DomainsNewManager() {
window.notify?.error('Не удалось запустить фоновое обновление', String(e)); window.notify?.error('Не удалось запустить фоновое обновление', String(e));
} }
}} }}
/> />
</div> )}
</div> />
</div>
<div className="row"> <div className="row">
<div className="col-lg-3"> <div className="col-lg-3">
{/* Add New Item Card */} {/* Add New Item Card */}
@@ -557,6 +562,10 @@ function DomainsNewManager() {
{/* Мета-информация будет показана внизу карточки */} {/* Мета-информация будет показана внизу карточки */}
{loading ? ( {loading ? (
<TableSkeleton rows={10} cols={3} /> <TableSkeleton rows={10} cols={3} />
) : paginatedItems.length === 0 ? (
<TableEmpty cols={3}>
<EmptyState title="Нет доменов" description="Импортируйте или добавьте записи, чтобы начать." action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>} />
</TableEmpty>
) : ( ) : (
<div className="table-responsive"> <div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0"> <table className="table card-table table-vcenter table-nowrap mb-0">
+22 -13
View File
@@ -17,9 +17,12 @@ import {
IconClock, IconClock,
IconFileText IconFileText
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import TableSkeleton from './components/TableSkeleton.jsx'; import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
import S3MetaBar from './components/S3MetaBar.jsx'; import S3MetaBar from './components/S3MetaBar.jsx';
import PageHeaderActions from './components/PageHeaderActions.jsx'; import PageHeaderActions from './components/PageHeaderActions.jsx';
import PageHeader from './components/PageHeader.jsx';
import EmptyState from './components/EmptyState.jsx';
import Breadcrumbs from './components/Breadcrumbs.jsx';
import ConfirmDialog from './components/ConfirmDialog.jsx'; import ConfirmDialog from './components/ConfirmDialog.jsx';
import WsUpdateModal from './components/WsUpdateModal.jsx'; import WsUpdateModal from './components/WsUpdateModal.jsx';
import ConfirmDiffModal from './components/ConfirmDiffModal.jsx'; import ConfirmDiffModal from './components/ConfirmDiffModal.jsx';
@@ -668,14 +671,17 @@ function IPRangesManager() {
</div> </div>
)} )}
<div className="page-header d-print-none mb-4"> <PageHeader
<div className="row align-items-center"> title="IP-диапазоны"
<div className="col"> pretitle={(
<h2 className="page-title">IP-диапазоны</h2> <Breadcrumbs items={[
<div className="page-pretitle">Главная / Данные / IP ranges</div> { label: 'Главная', to: '/dashboard' },
</div> { label: 'Данные' },
<div className="col-auto ms-auto d-print-none"> { label: 'IP ranges' },
<PageHeaderActions ]} />
)}
actions={(
<PageHeaderActions
loading={loading} loading={loading}
onRefresh={fetchItems} onRefresh={fetchItems}
disableRefresh={loading} disableRefresh={loading}
@@ -702,10 +708,9 @@ function IPRangesManager() {
window.notify?.error('Не удалось запустить фоновое обновление', String(e)); window.notify?.error('Не удалось запустить фоновое обновление', String(e));
} }
}} }}
/> />
</div> )}
</div> />
</div>
<div className="row"> <div className="row">
<div className="col-lg-3"> <div className="col-lg-3">
{/* Add New Item Card */} {/* Add New Item Card */}
@@ -814,6 +819,10 @@ function IPRangesManager() {
{/* Мета-информация будет показана внизу карточки */} {/* Мета-информация будет показана внизу карточки */}
{loading ? ( {loading ? (
<TableSkeleton rows={10} cols={3} /> <TableSkeleton rows={10} cols={3} />
) : paginatedItems.length === 0 ? (
<TableEmpty cols={3}>
<EmptyState title="Нет IP-диапазонов" description="Импортируйте или добавьте записи, чтобы начать." action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>} />
</TableEmpty>
) : ( ) : (
<div className="table-responsive"> <div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0"> <table className="table card-table table-vcenter table-nowrap mb-0">
+25
View File
@@ -0,0 +1,25 @@
import React from 'react'
import { Link } from 'react-router-dom'
function Breadcrumbs({ items = [] }) {
if (!Array.isArray(items) || items.length === 0) return null
return (
<nav aria-label="breadcrumb">
<ol className="breadcrumb">
{items.map((it, idx) => (
<li key={idx} className={`breadcrumb-item${idx === items.length - 1 ? ' active' : ''}`} aria-current={idx === items.length - 1 ? 'page' : undefined}>
{it.to && idx !== items.length - 1 ? (
<Link to={it.to}>{it.label}</Link>
) : (
<span>{it.label}</span>
)}
</li>
))}
</ol>
</nav>
)
}
export default Breadcrumbs
+31
View File
@@ -0,0 +1,31 @@
import React from 'react'
function EmptyState({
icon: Icon,
title = 'Нет данных',
description,
action,
}) {
return (
<div className="empty">
<div className="empty-icon">
{Icon ? <Icon size={48} className="text-muted" /> : null}
</div>
<p className="empty-title">{title}</p>
{description && (
<p className="empty-subtitle text-muted">
{description}
</p>
)}
{action && (
<div className="empty-action">
{action}
</div>
)}
</div>
)
}
export default EmptyState
+4 -1
View File
@@ -90,9 +90,12 @@ export function useNotify() {
// Единый тост-успех для мутаций (POST/DELETE/PUT/PATCH) // Единый тост-успех для мутаций (POST/DELETE/PUT/PATCH)
export function notifyMutationSuccess(message, details) { export function notifyMutationSuccess(message, details) {
// Тихий режим: по умолчанию успех показываем не тостом, а через inline-баннер на страницах.
// Оставляем возможность вручную вызвать тост при необходимости, если передан details?.forceToast === true
try { try {
const text = message || 'Операция выполнена'; const text = message || 'Операция выполнена';
if (typeof window !== 'undefined' && window.notify?.success) { const forceToast = Boolean(details && details.forceToast);
if (forceToast && typeof window !== 'undefined' && window.notify?.success) {
window.notify.success(text, details); window.notify.success(text, details);
} }
} catch {} } catch {}
+28
View File
@@ -0,0 +1,28 @@
import React from 'react'
function PageHeader({ title, pretitle, actions, meta }) {
return (
<div className="page-header d-print-none mb-4">
<div className="row align-items-center">
<div className="col">
{pretitle && (
<div className="page-pretitle">{pretitle}</div>
)}
<h2 className="page-title">{title}</h2>
{meta && (
<div className="text-muted small mt-1">{meta}</div>
)}
</div>
{actions && (
<div className="col-auto ms-auto d-print-none">
{actions}
</div>
)}
</div>
</div>
)
}
export default PageHeader
+46 -2
View File
@@ -5,7 +5,8 @@ import {
IconDeviceFloppy, IconDeviceFloppy,
IconPlayerPlay, IconPlayerPlay,
IconBolt, IconBolt,
IconEraser IconEraser,
IconDots
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import PortalDropdown from './PortalDropdown.jsx'; import PortalDropdown from './PortalDropdown.jsx';
import { IconClock } from '@tabler/icons-react'; import { IconClock } from '@tabler/icons-react';
@@ -31,6 +32,7 @@ function PageHeaderActions({
onOnlineUpdate, onOnlineUpdate,
onBackgroundUpdate, onBackgroundUpdate,
}) { }) {
const secondaryButtons = Boolean(onImport || onExport || onClear || onClearCommunities || onHistory);
// Рендер плейсхолдеров во время загрузки/перезагрузки страницы // Рендер плейсхолдеров во время загрузки/перезагрузки страницы
if (loading) { if (loading) {
const showUpdateGroup = Boolean(onBackgroundUpdate || onOnlineUpdate); const showUpdateGroup = Boolean(onBackgroundUpdate || onOnlineUpdate);
@@ -95,7 +97,7 @@ function PageHeaderActions({
)} )}
</div> </div>
<span className="vr d-none d-lg-inline" /> <span className="vr d-none d-lg-inline" />
<div className="btn-group"> <div className="btn-group d-none d-sm-inline-flex">
{onRefresh && ( {onRefresh && (
<button className="btn btn-outline-secondary" type="button" onClick={onRefresh} disabled={disableRefresh} title="Обновить данные"> <button className="btn btn-outline-secondary" type="button" onClick={onRefresh} disabled={disableRefresh} title="Обновить данные">
<IconRefresh className={loading ? 'spin me-1' : 'me-1'} /> Обновить <IconRefresh className={loading ? 'spin me-1' : 'me-1'} /> Обновить
@@ -159,6 +161,48 @@ function PageHeaderActions({
</div> </div>
)} )}
</div> </div>
{/* Компактное меню для маленьких экранов */}
{secondaryButtons && (
<div className="btn-group d-inline-flex d-sm-none">
<PortalDropdown
buttonClassName="btn btn-outline-secondary"
buttonProps={{ title: 'Ещё действия' }}
align="left"
buttonContent={<><IconDots className="me-1" /> Ещё</>}
>
{onRefresh && (
<button className="dropdown-item" type="button" onClick={onRefresh} disabled={disableRefresh}>
<IconRefresh className="me-1" /> Обновить
</button>
)}
{onImport && (
<button className="dropdown-item" type="button" onClick={onImport}>
<IconUpload className="me-1" /> Импорт
</button>
)}
{onExport && (
<button className="dropdown-item" type="button" onClick={onExport} disabled={disableExport}>
<IconDownload className="me-1" /> Экспорт
</button>
)}
{onClear && (
<button className="dropdown-item" type="button" onClick={onClear} disabled={disableClear}>
Очистить
</button>
)}
{onClearCommunities && (
<button className="dropdown-item" type="button" onClick={onClearCommunities} disabled={disableClearCommunities}>
<IconEraser className="me-1" /> Очистить community
</button>
)}
{onHistory && (
<button className="dropdown-item" type="button" onClick={onHistory}>
История
</button>
)}
</PortalDropdown>
</div>
)}
</div> </div>
); );
} }
+16
View File
@@ -26,4 +26,20 @@ export default function TableSkeleton({ rows = 10, cols = 3 }) {
); );
} }
export function TableEmpty({ cols = 3, children }) {
return (
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0">
<tbody>
<tr>
<td colSpan={cols} className="text-center text-muted py-5">
{children}
</td>
</tr>
</tbody>
</table>
</div>
);
}
+4
View File
@@ -1 +1,5 @@
/* Using Bootstrap styles, this file can be empty or used for custom global styles. */ /* Using Bootstrap styles, this file can be empty or used for custom global styles. */
/* Sticky footer helpers */
.page { min-height: 100vh; display: flex; flex-direction: column; }
.footer { margin-top: auto; }