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> <div></div>
</div> </div>
{/* Мета-информация будет показана внизу карточки */} </div>
{loading ? (
<TableSkeleton rows={10} cols={3} /> {/* Таблица без карточной подложки */}
) : paginatedItems.length === 0 ? ( {loading ? (
<TableEmpty cols={3}> <TableSkeleton rows={10} cols={3} flat />
<EmptyState ) : paginatedItems.length === 0 ? (
title="Нет ASN" <TableEmpty cols={3} flat>
description="Импортируйте или добавьте записи, чтобы начать." <EmptyState
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>} title="Нет ASN"
secondaryAction={<button className="btn btn-outline-primary" onClick={() => { description="Импортируйте или добавьте записи, чтобы начать."
const header = ['asn','community']; action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
const lines = [header, ['12345','65000:100']].map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(',')).join('\n'); secondaryAction={<button className="btn btn-outline-primary" onClick={() => {
const blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' }); const header = ['asn','community'];
const url = URL.createObjectURL(blob); const lines = [header, ['12345','65000:100']].map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(',')).join('\n');
const a = document.createElement('a'); a.href = url; a.download = 'asns_sample.csv'; a.click(); URL.revokeObjectURL(url); const blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' });
}}>Шаблон CSV</button>} const url = URL.createObjectURL(blob);
/> const a = document.createElement('a'); a.href = url; a.download = 'asns_sample.csv'; a.click(); URL.revokeObjectURL(url);
</TableEmpty> }}>Шаблон CSV</button>}
) : ( />
</TableEmpty>
) : (
<div className="table-responsive"> <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> <thead>
<tr> <tr>
<th style={{width: '40px'}}> <th style={{width: '40px'}}>
@@ -824,7 +826,6 @@ function ASNsNewManager() {
onPageChange={setCurrentPage} onPageChange={setCurrentPage}
/> />
<S3MetaBar keys={["asns"]} /> <S3MetaBar keys={["asns"]} />
</div>
</div> </div>
</div> </div>
+25 -23
View File
@@ -684,27 +684,29 @@ function DomainsNewManager() {
</div> </div>
</div> </div>
</div> </div>
{/* Мета-информация будет показана внизу карточки */} </div>
{loading ? (
<TableSkeleton rows={10} cols={4} hasCheckbox={true} /> {/* Таблица без карточной подложки (только горизонтальные разделители) */}
) : paginatedItems.length === 0 ? ( {loading ? (
<TableEmpty cols={3}> <TableSkeleton rows={10} cols={4} hasCheckbox={true} flat />
<EmptyState ) : paginatedItems.length === 0 ? (
title="Нет доменов" <TableEmpty cols={3} flat>
description="Импортируйте или добавьте записи, чтобы начать." <EmptyState
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>} title="Нет доменов"
secondaryAction={<button className="btn btn-outline-primary" onClick={() => { description="Импортируйте или добавьте записи, чтобы начать."
const header = ['domain','community']; action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
const lines = [header, ['example.com','65000:100']].map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(',')).join('\n'); secondaryAction={<button className="btn btn-outline-primary" onClick={() => {
const blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' }); const header = ['domain','community'];
const url = URL.createObjectURL(blob); const lines = [header, ['example.com','65000:100']].map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(',')).join('\n');
const a = document.createElement('a'); a.href = url; a.download = 'domains_sample.csv'; a.click(); URL.revokeObjectURL(url); const blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' });
}}>Шаблон CSV</button>} const url = URL.createObjectURL(blob);
/> const a = document.createElement('a'); a.href = url; a.download = 'domains_sample.csv'; a.click(); URL.revokeObjectURL(url);
</TableEmpty> }}>Шаблон CSV</button>}
) : ( />
</TableEmpty>
) : (
<div className="table-responsive"> <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> <thead>
<tr> <tr>
<th style={{ width: '40px' }}> <th style={{ width: '40px' }}>
@@ -854,9 +856,9 @@ function DomainsNewManager() {
</tbody> </tbody>
</table> </table>
</div> </div>
)} )}
{/* Пагинация */} {/* Пагинация */}
<Pagination <Pagination
currentPage={currentPage} currentPage={currentPage}
totalPages={totalPages} totalPages={totalPages}
totalItems={totalItems || filtered.length} totalItems={totalItems || filtered.length}
+16 -15
View File
@@ -923,21 +923,23 @@ function IPRangesManager() {
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
{/* Мета-информация будет показана внизу карточки */} </div>
{loading ? (
<TableSkeleton rows={10} cols={3} /> {/* Таблица без карточной подложки */}
) : paginatedItems.length === 0 ? ( {loading ? (
<TableEmpty cols={3}> <TableSkeleton rows={10} cols={3} flat />
<EmptyState ) : paginatedItems.length === 0 ? (
title="Нет IP-диапазонов" <TableEmpty cols={3} flat>
description="Импортируйте или добавьте записи, чтобы начать." <EmptyState
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>} title="Нет IP-диапазонов"
secondaryAction={<button className="btn btn-outline-primary" onClick={downloadSampleCsv}>Шаблон CSV</button>} description="Импортируйте или добавьте записи, чтобы начать."
/> action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
</TableEmpty> secondaryAction={<button className="btn btn-outline-primary" onClick={downloadSampleCsv}>Шаблон CSV</button>}
) : ( />
</TableEmpty>
) : (
<div className="table-responsive"> <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> <thead>
<tr> <tr>
<th style={{width: '40px'}}> <th style={{width: '40px'}}>
@@ -1057,7 +1059,6 @@ function IPRangesManager() {
onPageChange={setCurrentPage} onPageChange={setCurrentPage}
/> />
<S3MetaBar keys={["ipRanges"]} /> <S3MetaBar keys={["ipRanges"]} />
</div>
</div> </div>
</div> </div>
+5 -2
View File
@@ -36,6 +36,7 @@ import Tooltip from './Tooltip.jsx';
* @param {number} props.skeletonRows - Количество строк скелетона (default: 10) * @param {number} props.skeletonRows - Количество строк скелетона (default: 10)
* @param {boolean} props.striped - Чередование фона строк (Tabler UX, default: true) * @param {boolean} props.striped - Чередование фона строк (Tabler UX, default: true)
* @param {boolean} props.compact - Уменьшенные отступы ячеек (default: false) * @param {boolean} props.compact - Уменьшенные отступы ячеек (default: false)
* @param {boolean} props.flat - Таблица без карточной подложки: только горизонтальные разделители (default: false)
*/ */
function DataTable({ function DataTable({
columns = [], columns = [],
@@ -60,6 +61,7 @@ function DataTable({
skeletonRows = 10, skeletonRows = 10,
striped = true, striped = true,
compact = false, compact = false,
flat = false,
}) { }) {
const hasActions = onEdit || onDelete || onCopy || actions.length > 0; const hasActions = onEdit || onDelete || onCopy || actions.length > 0;
const isEditing = (item) => inlineEdit && item[itemKey] === inlineEdit.editingKey; 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 allSelected = items.length > 0 && items.every(i => selectedItems.has(i[itemKey]));
const tableClass = [ const tableClass = [
'table card-table table-vcenter table-nowrap mb-0', 'table table-vcenter table-nowrap mb-0',
striped && 'table-striped', flat ? 'table-flat' : 'card-table',
!flat && striped && 'table-striped',
compact && 'table-sm', compact && 'table-sm',
].filter(Boolean).join(' '); ].filter(Boolean).join(' ');
+11 -4
View File
@@ -1,7 +1,11 @@
/** /**
* Skeleton loader для таблиц с анимацией pulse * 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 = () => ( const Row = () => (
<tr> <tr>
{hasCheckbox && ( {hasCheckbox && (
@@ -18,7 +22,7 @@ export default function TableSkeleton({ rows = 10, cols = 3, hasCheckbox = false
); );
return ( return (
<div className="table-responsive"> <div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0"> <table className={tableClass}>
<thead> <thead>
<tr> <tr>
{hasCheckbox && <th style={{ width: '40px' }}></th>} {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 ( return (
<div className="table-responsive"> <div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0"> <table className={tableClass}>
<tbody> <tbody>
<tr> <tr>
<td colSpan={cols} className="text-center text-muted py-5"> <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); --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 {
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;