feat(ui): refine fleet search/filter toolbar with status chips

Adopt Portainer/Beszel-style fleet controls: prominent search, status
facet chips with counts, active filter chips, result count, and
primary-row sort/select. Keep advanced filters collapsed for secondary
facets. Tests and production dist rebuild for gateway 8788.
This commit is contained in:
chick
2026-07-21 23:31:08 +08:00
parent 7d0363d275
commit 3c7018a792
3 changed files with 469 additions and 84 deletions
+219 -60
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import {
buildFleetTableViewModel,
fleetStatusKind,
type FleetAuthFilter,
type FleetFilter,
type FleetInstance,
@@ -252,6 +253,98 @@ export function FleetPage({
};
}, [snapshot]);
const statusFacetCounts = useMemo(() => {
const counts: Record<FleetFilter, number> = {
all: snapshot?.instances.length ?? 0,
online: 0,
auth: 0,
offline: 0,
unknown: 0,
};
for (const instance of snapshot?.instances ?? []) {
const kind = fleetStatusKind(snapshot?.statuses.get(instance.id));
counts[kind] += 1;
}
return counts;
}, [snapshot]);
const activeFilterChips = useMemo(() => {
const chips: Array<{ key: string; label: string; onClear: () => void }> = [];
const trimmedQuery = query.trim();
if (trimmedQuery) {
chips.push({
key: 'query',
label: `搜索:${trimmedQuery}`,
onClear: () => {
setQuery('');
setPage(1);
},
});
}
if (filter !== 'all') {
chips.push({
key: 'status',
label: `状态:${STATUS_LABELS[filter] ?? filter}`,
onClear: () => {
setFilter('all');
setPage(1);
},
});
}
if (auth !== 'all') {
const authLabel =
auth === 'authenticated' ? '已认证' : auth === 'required' ? '需要认证' : '未知';
chips.push({
key: 'auth',
label: `认证:${authLabel}`,
onClear: () => {
setAuth('all');
setPage(1);
},
});
}
if (capability) {
chips.push({
key: 'capability',
label: `能力:${capabilityLabel(capability)}`,
onClear: () => {
setCapability('');
setPage(1);
},
});
}
if (version) {
chips.push({
key: 'version',
label: `版本:${version}`,
onClear: () => {
setVersion('');
setPage(1);
},
});
}
if (tag) {
chips.push({
key: 'tag',
label: `标签:${tag}`,
onClear: () => {
setTag('');
setPage(1);
},
});
}
return chips;
}, [auth, capability, filter, query, tag, version]);
const advancedFilterCount = useMemo(
() =>
(auth !== 'all' ? 1 : 0) +
(capability ? 1 : 0) +
(version ? 1 : 0) +
(tag ? 1 : 0),
[auth, capability, tag, version],
);
useEffect(() => {
if (page !== model.page) setPage(model.page);
}, [model.page, page]);
@@ -260,6 +353,16 @@ export function FleetPage({
action();
setPage(1);
}
function clearAllFilters(): void {
setQuery('');
setFilter('all');
setAuth('all');
setCapability('');
setVersion('');
setTag('');
setPage(1);
}
function toggleOne(id: string, checked: boolean): void {
setSelectedIds((current) => {
const next = new Set(current);
@@ -562,35 +665,130 @@ export function FleetPage({
) : null}
</div>
) : null}
<div className="fleet-toolbar">
<div className="fleet-toolbar-primary">
<label>
<span></span>
<div className="fleet-toolbar" role="search" aria-label="实例搜索与筛选">
<div className="fleet-search-row">
<label className="fleet-search-field">
<span className="visually-hidden"></span>
<span className="fleet-search-glyph" aria-hidden="true">
</span>
<input
type="search"
value={query}
onChange={(event) => resetPage(() => setQuery(event.currentTarget.value))}
placeholder="名称、ID、标签、源地址…"
placeholder="搜索名称、ID、标签、源地址…"
autoComplete="off"
spellCheck={false}
/>
</label>
<label>
<span></span>
<select
value={filter}
onChange={(event) =>
resetPage(() => setFilter(event.currentTarget.value as FleetFilter))
}
>
<option value="all"></option>
<option value="online">线</option>
<option value="auth"></option>
<option value="offline">线</option>
<option value="unknown"></option>
</select>
{query ? (
<button
type="button"
className="fleet-search-clear"
aria-label="清除搜索"
onClick={() => resetPage(() => setQuery(''))}
>
</button>
) : null}
</label>
</div>
<div className="fleet-status-chips" role="group" aria-label="按状态筛选">
{(
[
['all', '全部'],
['online', '在线'],
['auth', '需认证'],
['offline', '离线'],
['unknown', '未知'],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
className={`fleet-status-chip${filter === value ? ' is-active' : ''}`}
aria-pressed={filter === value}
onClick={() => resetPage(() => setFilter(value))}
>
<span>{label}</span>
<strong>{statusFacetCounts[value]}</strong>
</button>
))}
</div>
<div className="fleet-toolbar-meta">
<p className="fleet-result-count" aria-live="polite">
<strong>{model.filteredCount}</strong> / {model.totalCount}
{model.pageCount > 1 ? ` · 第 ${model.page}/${model.pageCount}` : null}
</p>
<div className="fleet-toolbar-controls">
<label className="fleet-sort-field">
<span className="visually-hidden"></span>
<select
aria-label="排序"
value={`${sort.column}:${sort.direction}`}
onChange={(event) => {
const [column, direction] = event.currentTarget.value.split(':') as [
FleetSortColumn,
SortDirection,
];
resetPage(() => setSort({ column, direction }));
}}
>
{(Object.keys(SORT_COLUMN_LABELS) as FleetSortColumn[]).flatMap((column) => [
<option key={`${column}:asc`} value={`${column}:asc`}>
{SORT_COLUMN_LABELS[column]} ·
</option>,
<option key={`${column}:desc`} value={`${column}:desc`}>
{SORT_COLUMN_LABELS[column]} ·
</option>,
])}
</select>
</label>
<div className="fleet-select-all" role="group" aria-label="本页选择">
<button
type="button"
onClick={() => toggleVisible(true)}
disabled={model.rows.length === 0}
>
</button>
<button
type="button"
onClick={() => toggleVisible(false)}
disabled={model.selectedIds.length === 0}
>
</button>
</div>
</div>
</div>
{activeFilterChips.length > 0 ? (
<div className="fleet-active-filters" aria-label="当前筛选条件">
{activeFilterChips.map((chip) => (
<button
key={chip.key}
type="button"
className="fleet-active-chip"
onClick={chip.onClear}
aria-label={`移除筛选 ${chip.label}`}
>
<span>{chip.label}</span>
<span aria-hidden="true">×</span>
</button>
))}
<button type="button" className="fleet-clear-filters" onClick={clearAllFilters}>
</button>
</div>
) : null}
<details className="fleet-advanced-filters">
<summary></summary>
<summary>
{advancedFilterCount > 0 ? (
<span className="fleet-advanced-count" aria-label={`已启用 ${advancedFilterCount}`}>
{advancedFilterCount}
</span>
) : null}
</summary>
<div className="fleet-advanced-filter-grid">
<label>
<span></span>
@@ -616,45 +814,6 @@ export function FleetPage({
)}
{selectFilter('版本', version, setVersion, model.facets.versions, '全部版本')}
{selectFilter('标签', tag, setTag, model.facets.tags, '全部标签')}
<label>
<span></span>
<select
aria-label="排序"
value={`${sort.column}:${sort.direction}`}
onChange={(event) => {
const [column, direction] = event.currentTarget.value.split(':') as [
FleetSortColumn,
SortDirection,
];
resetPage(() => setSort({ column, direction }));
}}
>
{(Object.keys(SORT_COLUMN_LABELS) as FleetSortColumn[]).flatMap((column) => [
<option key={`${column}:asc`} value={`${column}:asc`}>
{SORT_COLUMN_LABELS[column]} ·
</option>,
<option key={`${column}:desc`} value={`${column}:desc`}>
{SORT_COLUMN_LABELS[column]} ·
</option>,
])}
</select>
</label>
<div className="fleet-select-all">
<button
type="button"
onClick={() => toggleVisible(true)}
disabled={model.rows.length === 0}
>
</button>
<button
type="button"
onClick={() => toggleVisible(false)}
disabled={model.selectedIds.length === 0}
>
</button>
</div>
</div>
</details>
</div>