refactor(Tables): implement flat table styling across ASNs, Domains, and IPRanges managers for improved visual consistency and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 58s

This commit is contained in:
2026-02-16 16:20:01 +07:00
parent 94efee905a
commit a322de4129
6 changed files with 128 additions and 65 deletions
+22 -21
View File
@@ -674,27 +674,29 @@ function ASNsNewManager() {
</div>
<div></div>
</div>
{/* Мета-информация будет показана внизу карточки */}
{loading ? (
<TableSkeleton rows={10} cols={3} />
) : paginatedItems.length === 0 ? (
<TableEmpty cols={3}>
<EmptyState
title="Нет ASN"
description="Импортируйте или добавьте записи, чтобы начать."
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
secondaryAction={<button className="btn btn-outline-primary" onClick={() => {
const header = ['asn','community'];
const lines = [header, ['12345','65000:100']].map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(',')).join('\n');
const blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'asns_sample.csv'; a.click(); URL.revokeObjectURL(url);
}}>Шаблон CSV</button>}
/>
</TableEmpty>
) : (
</div>
{/* Таблица без карточной подложки */}
{loading ? (
<TableSkeleton rows={10} cols={3} flat />
) : paginatedItems.length === 0 ? (
<TableEmpty cols={3} flat>
<EmptyState
title="Нет ASN"
description="Импортируйте или добавьте записи, чтобы начать."
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
secondaryAction={<button className="btn btn-outline-primary" onClick={() => {
const header = ['asn','community'];
const lines = [header, ['12345','65000:100']].map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(',')).join('\n');
const blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'asns_sample.csv'; a.click(); URL.revokeObjectURL(url);
}}>Шаблон CSV</button>}
/>
</TableEmpty>
) : (
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0">
<table className="table table-flat table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th style={{width: '40px'}}>
@@ -824,7 +826,6 @@ function ASNsNewManager() {
onPageChange={setCurrentPage}
/>
<S3MetaBar keys={["asns"]} />
</div>
</div>
</div>
+25 -23
View File
@@ -684,27 +684,29 @@ function DomainsNewManager() {
</div>
</div>
</div>
{/* Мета-информация будет показана внизу карточки */}
{loading ? (
<TableSkeleton rows={10} cols={4} hasCheckbox={true} />
) : paginatedItems.length === 0 ? (
<TableEmpty cols={3}>
<EmptyState
title="Нет доменов"
description="Импортируйте или добавьте записи, чтобы начать."
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
secondaryAction={<button className="btn btn-outline-primary" onClick={() => {
const header = ['domain','community'];
const lines = [header, ['example.com','65000:100']].map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(',')).join('\n');
const blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'domains_sample.csv'; a.click(); URL.revokeObjectURL(url);
}}>Шаблон CSV</button>}
/>
</TableEmpty>
) : (
</div>
{/* Таблица без карточной подложки (только горизонтальные разделители) */}
{loading ? (
<TableSkeleton rows={10} cols={4} hasCheckbox={true} flat />
) : paginatedItems.length === 0 ? (
<TableEmpty cols={3} flat>
<EmptyState
title="Нет доменов"
description="Импортируйте или добавьте записи, чтобы начать."
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
secondaryAction={<button className="btn btn-outline-primary" onClick={() => {
const header = ['domain','community'];
const lines = [header, ['example.com','65000:100']].map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(',')).join('\n');
const blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'domains_sample.csv'; a.click(); URL.revokeObjectURL(url);
}}>Шаблон CSV</button>}
/>
</TableEmpty>
) : (
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0">
<table className="table table-flat table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th style={{ width: '40px' }}>
@@ -854,9 +856,9 @@ function DomainsNewManager() {
</tbody>
</table>
</div>
)}
{/* Пагинация */}
<Pagination
)}
{/* Пагинация */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems || filtered.length}
+16 -15
View File
@@ -923,21 +923,23 @@ function IPRangesManager() {
</Tooltip>
</div>
</div>
{/* Мета-информация будет показана внизу карточки */}
{loading ? (
<TableSkeleton rows={10} cols={3} />
) : paginatedItems.length === 0 ? (
<TableEmpty cols={3}>
<EmptyState
title="Нет IP-диапазонов"
description="Импортируйте или добавьте записи, чтобы начать."
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
secondaryAction={<button className="btn btn-outline-primary" onClick={downloadSampleCsv}>Шаблон CSV</button>}
/>
</TableEmpty>
) : (
</div>
{/* Таблица без карточной подложки */}
{loading ? (
<TableSkeleton rows={10} cols={3} flat />
) : paginatedItems.length === 0 ? (
<TableEmpty cols={3} flat>
<EmptyState
title="Нет IP-диапазонов"
description="Импортируйте или добавьте записи, чтобы начать."
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
secondaryAction={<button className="btn btn-outline-primary" onClick={downloadSampleCsv}>Шаблон CSV</button>}
/>
</TableEmpty>
) : (
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0">
<table className="table table-flat table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th style={{width: '40px'}}>
@@ -1057,7 +1059,6 @@ function IPRangesManager() {
onPageChange={setCurrentPage}
/>
<S3MetaBar keys={["ipRanges"]} />
</div>
</div>
</div>
+5 -2
View File
@@ -36,6 +36,7 @@ import Tooltip from './Tooltip.jsx';
* @param {number} props.skeletonRows - Количество строк скелетона (default: 10)
* @param {boolean} props.striped - Чередование фона строк (Tabler UX, default: true)
* @param {boolean} props.compact - Уменьшенные отступы ячеек (default: false)
* @param {boolean} props.flat - Таблица без карточной подложки: только горизонтальные разделители (default: false)
*/
function DataTable({
columns = [],
@@ -60,6 +61,7 @@ function DataTable({
skeletonRows = 10,
striped = true,
compact = false,
flat = false,
}) {
const hasActions = onEdit || onDelete || onCopy || actions.length > 0;
const isEditing = (item) => inlineEdit && item[itemKey] === inlineEdit.editingKey;
@@ -220,8 +222,9 @@ function DataTable({
const allSelected = items.length > 0 && items.every(i => selectedItems.has(i[itemKey]));
const tableClass = [
'table card-table table-vcenter table-nowrap mb-0',
striped && 'table-striped',
'table table-vcenter table-nowrap mb-0',
flat ? 'table-flat' : 'card-table',
!flat && striped && 'table-striped',
compact && 'table-sm',
].filter(Boolean).join(' ');
+11 -4
View File
@@ -1,7 +1,11 @@
/**
* Skeleton loader для таблиц с анимацией pulse
* @param {boolean} flat - без карточной подложки (table-flat)
*/
export default function TableSkeleton({ rows = 10, cols = 3, hasCheckbox = false }) {
export default function TableSkeleton({ rows = 10, cols = 3, hasCheckbox = false, flat = false }) {
const tableClass = flat
? 'table table-flat table-vcenter table-nowrap mb-0'
: 'table card-table table-vcenter table-nowrap mb-0';
const Row = () => (
<tr>
{hasCheckbox && (
@@ -18,7 +22,7 @@ export default function TableSkeleton({ rows = 10, cols = 3, hasCheckbox = false
);
return (
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0">
<table className={tableClass}>
<thead>
<tr>
{hasCheckbox && <th style={{ width: '40px' }}></th>}
@@ -35,10 +39,13 @@ export default function TableSkeleton({ rows = 10, cols = 3, hasCheckbox = false
);
}
export function TableEmpty({ cols = 3, children }) {
export function TableEmpty({ cols = 3, flat = false, children }) {
const tableClass = flat
? 'table table-flat table-vcenter table-nowrap mb-0'
: 'table card-table table-vcenter table-nowrap mb-0';
return (
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0">
<table className={tableClass}>
<tbody>
<tr>
<td colSpan={cols} className="text-center text-muted py-5">
+49
View File
@@ -332,6 +332,55 @@ button:focus-visible,
--tblr-table-bg-type: rgba(32, 107, 196, 0.12);
}
/* ===== Table flat (без карточной подложки, как UniFi) ===== */
/* Только горизонтальные разделители, thead темнее, без вертикальных границ */
.table-flat {
--tblr-table-border-color: var(--tblr-border-color);
background: transparent;
}
.table-flat thead th {
font-weight: 600;
border-top: none;
border-left: none;
border-right: none;
border-bottom: 1px solid var(--tblr-table-border-color);
background: var(--tblr-bg-surface-secondary, rgba(0, 0, 0, 0.03));
color: var(--tblr-body-color);
padding: 0.75rem 1rem;
white-space: nowrap;
}
.table-flat tbody td {
border-top: none;
border-left: none;
border-right: none;
border-bottom: 1px solid var(--tblr-table-border-color);
background: transparent;
padding: 0.75rem 1rem;
vertical-align: middle;
}
.table-flat tbody tr:hover td {
background: var(--tblr-bg-surface-tertiary, rgba(0, 0, 0, 0.02));
}
.table-flat tbody tr.table-selected td,
.table-flat tbody tr.table-active td {
background: rgba(32, 107, 196, 0.08);
}
.table-flat tbody tr.table-selected:hover td,
.table-flat tbody tr.table-active:hover td {
background: rgba(32, 107, 196, 0.12);
}
/* В тёмной теме — чуть другие оттенки для thead */
[data-bs-theme="dark"] .table-flat thead th {
background: rgba(255, 255, 255, 0.06);
}
[data-bs-theme="dark"] .table-flat tbody tr:hover td {
background: rgba(255, 255, 255, 0.04);
}
[data-bs-theme="dark"] .table-flat tbody tr.table-selected td,
[data-bs-theme="dark"] .table-flat tbody tr.table-active td {
background: rgba(32, 107, 196, 0.15);
}
.user-select-none {
user-select: none;
-webkit-user-select: none;