feat(clients): add inbound filter + mobile page-size control

Filter bar gets an Inbound select next to Protocol — the dropdown is
narrowed to inbounds matching the chosen protocol (or shows everything
when no protocol is picked), with remark search inside the dropdown.
Choosing a protocol clears any inbound selection that no longer fits.

Server side, ClientPageParams gains an Inbound int and ListPaged runs a
clientMatchesInbound check after the protocol filter. The selection
persists in clientsFilterState localStorage alongside the existing
search/filter/protocol entries.

Mobile clients view also grows the AntD Pagination control that was
previously only on the desktop table, so page size / page navigation
are reachable from phones.
This commit is contained in:
MHSanaei
2026-05-23 23:31:41 +02:00
parent 6185db586a
commit 867a145979
4 changed files with 85 additions and 6 deletions

View File

@@ -60,6 +60,7 @@ export interface ClientQueryParams {
search?: string;
filter?: string;
protocol?: string;
inbound?: number;
sort?: string;
order?: 'ascend' | 'descend';
}
@@ -107,6 +108,7 @@ export function useClients() {
&& (prev.search ?? '') === (next.search ?? '')
&& (prev.filter ?? '') === (next.filter ?? '')
&& (prev.protocol ?? '') === (next.protocol ?? '')
&& (prev.inbound ?? 0) === (next.inbound ?? 0)
&& (prev.sort ?? '') === (next.sort ?? '')
&& (prev.order ?? '') === (next.order ?? '')
) return prev;
@@ -136,6 +138,7 @@ export function useClients() {
if (p.search) sp.set('search', p.search);
if (p.filter) sp.set('filter', p.filter);
if (p.protocol) sp.set('protocol', p.protocol);
if (p.inbound && p.inbound > 0) sp.set('inbound', String(p.inbound));
if (p.sort) sp.set('sort', p.sort);
if (p.order) sp.set('order', p.order);
return sp.toString();

View File

@@ -145,6 +145,18 @@
padding: 4px 4px 8px;
}
.card-pagination {
display: flex;
justify-content: center;
flex-wrap: wrap;
padding: 4px 0 8px;
}
.card-pagination .ant-pagination-options-size-changer,
.card-pagination .ant-pagination-options-size-changer .ant-select-selector {
min-width: 88px !important;
}
.bulk-count {
font-size: 12px;
background: rgba(22, 119, 255, 0.12);

View File

@@ -11,6 +11,7 @@ import {
Input,
Layout,
Modal,
Pagination,
Popover,
Radio,
Row,
@@ -71,19 +72,22 @@ interface FilterState {
searchKey: string;
filterBy: string;
protocolFilter?: string;
inboundFilter?: number;
}
function readFilterState(): FilterState {
try {
const raw = JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}');
const inb = typeof raw.inboundFilter === 'number' && raw.inboundFilter > 0 ? raw.inboundFilter : undefined;
return {
enableFilter: !!raw.enableFilter,
searchKey: raw.searchKey || '',
filterBy: raw.filterBy || '',
protocolFilter: raw.protocolFilter,
inboundFilter: inb,
};
} catch {
return { enableFilter: false, searchKey: '', filterBy: '', protocolFilter: undefined };
return { enableFilter: false, searchKey: '', filterBy: '', protocolFilter: undefined, inboundFilter: undefined };
}
}
@@ -132,6 +136,7 @@ export default function ClientsPage() {
const [searchKey, setSearchKey] = useState(initial.searchKey);
const [filterBy, setFilterBy] = useState(initial.filterBy);
const [protocolFilter, setProtocolFilter] = useState<string | undefined>(initial.protocolFilter);
const [inboundFilter, setInboundFilter] = useState<number | undefined>(initial.inboundFilter);
const [sortColumn, setSortColumn] = useState<string | null>(null);
const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(null);
@@ -143,9 +148,9 @@ export default function ClientsPage() {
useEffect(() => {
localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({
enableFilter, searchKey, filterBy, protocolFilter,
enableFilter, searchKey, filterBy, protocolFilter, inboundFilter,
}));
}, [enableFilter, searchKey, filterBy, protocolFilter]);
}, [enableFilter, searchKey, filterBy, protocolFilter, inboundFilter]);
useEffect(() => {
const handle = window.setTimeout(() => setDebouncedSearch(searchKey), 300);
@@ -156,7 +161,7 @@ export default function ClientsPage() {
// Reset to page 1 whenever a filter or sort changes — otherwise an empty
// result set on a high page number leaves the user staring at "no clients".
setCurrentPage(1);
}, [debouncedSearch, enableFilter, filterBy, protocolFilter, sortColumn, sortOrder]);
}, [debouncedSearch, enableFilter, filterBy, protocolFilter, inboundFilter, sortColumn, sortOrder]);
useEffect(() => {
setQuery({
@@ -165,10 +170,11 @@ export default function ClientsPage() {
search: enableFilter ? '' : debouncedSearch,
filter: enableFilter ? (filterBy || '') : '',
protocol: protocolFilter || '',
inbound: inboundFilter,
sort: sortColumn || undefined,
order: sortOrder || undefined,
});
}, [setQuery, currentPage, tablePageSize, enableFilter, debouncedSearch, filterBy, protocolFilter, sortColumn, sortOrder]);
}, [setQuery, currentPage, tablePageSize, enableFilter, debouncedSearch, filterBy, protocolFilter, inboundFilter, sortColumn, sortOrder]);
useEffect(() => {
if (pageSize > 0) {
@@ -732,13 +738,37 @@ export default function ClientsPage() {
)}
<Select
value={protocolFilter}
onChange={(v) => setProtocolFilter(v)}
onChange={(v) => {
setProtocolFilter(v);
if (v && inboundFilter) {
const ib = inbounds.find((x) => x.id === inboundFilter);
if (!ib || ib.protocol !== v) setInboundFilter(undefined);
}
}}
allowClear
placeholder={t('pages.inbounds.protocol')}
size={isMobile ? 'small' : 'middle'}
style={{ width: 150 }}
options={protocolOptions.map((p) => ({ value: p, label: p }))}
/>
<Select
value={inboundFilter}
onChange={(v) => setInboundFilter(v)}
allowClear
showSearch
optionFilterProp="label"
placeholder={t('inbounds')}
size={isMobile ? 'small' : 'middle'}
style={{ minWidth: 160, maxWidth: 240 }}
options={inbounds
.filter((ib) => !protocolFilter || ib.protocol === protocolFilter)
.map((ib) => ({
value: ib.id,
label: ib.remark
? `${ib.remark} (${ib.protocol || ''}${ib.port ? `:${ib.port}` : ''})`
: `#${ib.id} ${ib.protocol || ''}${ib.port ? `:${ib.port}` : ''}`,
}))}
/>
</div>
{!isMobile ? (
@@ -784,6 +814,24 @@ export default function ClientsPage() {
<div>{t('pages.clients.empty')}</div>
</div>
)}
{filteredClients.length > 0 && (
<div className="card-pagination">
<Pagination
current={currentPage}
pageSize={tablePageSize}
total={filtered}
showSizeChanger={filtered > 10}
pageSizeOptions={['10', '25', '50', '100', '200']}
hideOnSinglePage={filtered <= tablePageSize}
size="small"
showTotal={(n) => `${n}`}
onChange={(p, s) => {
setCurrentPage(p);
if (s && s !== tablePageSize) setTablePageSize(s);
}}
/>
</div>
)}
{filteredClients.map((row) => {
const bucket = clientBucket(row);
return (

View File

@@ -828,6 +828,7 @@ type ClientPageParams struct {
Search string `form:"search"`
Filter string `form:"filter"`
Protocol string `form:"protocol"`
Inbound int `form:"inbound"`
Sort string `form:"sort"`
Order string `form:"order"`
}
@@ -928,6 +929,9 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
if params.Protocol != "" && !clientMatchesProtocol(c, params.Protocol, protocolByInbound) {
continue
}
if params.Inbound > 0 && !clientMatchesInbound(c, params.Inbound) {
continue
}
if params.Filter != "" && !clientMatchesBucket(c, params.Filter, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) {
continue
}
@@ -1046,6 +1050,18 @@ func clientMatchesProtocol(c ClientWithAttachments, protocol string, byInbound m
return false
}
func clientMatchesInbound(c ClientWithAttachments, inboundId int) bool {
if inboundId <= 0 {
return true
}
for _, id := range c.InboundIds {
if id == inboundId {
return true
}
}
return false
}
func clientMatchesBucket(c ClientWithAttachments, bucket string, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) bool {
if bucket == "" {
return true