mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-26 07:08:01 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user