mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-31 18:24:10 +00:00
feat(clients): add inbound filter + mobile page-size control
Some checks are pending
CI / go-test (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / frontend (push) Waiting to run
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (push) Waiting to run
Some checks are pending
CI / go-test (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / frontend (push) Waiting to run
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (push) Waiting to run
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:
parent
6185db586a
commit
867a145979
4 changed files with 85 additions and 6 deletions
|
|
@ -60,6 +60,7 @@ export interface ClientQueryParams {
|
||||||
search?: string;
|
search?: string;
|
||||||
filter?: string;
|
filter?: string;
|
||||||
protocol?: string;
|
protocol?: string;
|
||||||
|
inbound?: number;
|
||||||
sort?: string;
|
sort?: string;
|
||||||
order?: 'ascend' | 'descend';
|
order?: 'ascend' | 'descend';
|
||||||
}
|
}
|
||||||
|
|
@ -107,6 +108,7 @@ export function useClients() {
|
||||||
&& (prev.search ?? '') === (next.search ?? '')
|
&& (prev.search ?? '') === (next.search ?? '')
|
||||||
&& (prev.filter ?? '') === (next.filter ?? '')
|
&& (prev.filter ?? '') === (next.filter ?? '')
|
||||||
&& (prev.protocol ?? '') === (next.protocol ?? '')
|
&& (prev.protocol ?? '') === (next.protocol ?? '')
|
||||||
|
&& (prev.inbound ?? 0) === (next.inbound ?? 0)
|
||||||
&& (prev.sort ?? '') === (next.sort ?? '')
|
&& (prev.sort ?? '') === (next.sort ?? '')
|
||||||
&& (prev.order ?? '') === (next.order ?? '')
|
&& (prev.order ?? '') === (next.order ?? '')
|
||||||
) return prev;
|
) return prev;
|
||||||
|
|
@ -136,6 +138,7 @@ export function useClients() {
|
||||||
if (p.search) sp.set('search', p.search);
|
if (p.search) sp.set('search', p.search);
|
||||||
if (p.filter) sp.set('filter', p.filter);
|
if (p.filter) sp.set('filter', p.filter);
|
||||||
if (p.protocol) sp.set('protocol', p.protocol);
|
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.sort) sp.set('sort', p.sort);
|
||||||
if (p.order) sp.set('order', p.order);
|
if (p.order) sp.set('order', p.order);
|
||||||
return sp.toString();
|
return sp.toString();
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,18 @@
|
||||||
padding: 4px 4px 8px;
|
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 {
|
.bulk-count {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
background: rgba(22, 119, 255, 0.12);
|
background: rgba(22, 119, 255, 0.12);
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
Input,
|
Input,
|
||||||
Layout,
|
Layout,
|
||||||
Modal,
|
Modal,
|
||||||
|
Pagination,
|
||||||
Popover,
|
Popover,
|
||||||
Radio,
|
Radio,
|
||||||
Row,
|
Row,
|
||||||
|
|
@ -71,19 +72,22 @@ interface FilterState {
|
||||||
searchKey: string;
|
searchKey: string;
|
||||||
filterBy: string;
|
filterBy: string;
|
||||||
protocolFilter?: string;
|
protocolFilter?: string;
|
||||||
|
inboundFilter?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readFilterState(): FilterState {
|
function readFilterState(): FilterState {
|
||||||
try {
|
try {
|
||||||
const raw = JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}');
|
const raw = JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}');
|
||||||
|
const inb = typeof raw.inboundFilter === 'number' && raw.inboundFilter > 0 ? raw.inboundFilter : undefined;
|
||||||
return {
|
return {
|
||||||
enableFilter: !!raw.enableFilter,
|
enableFilter: !!raw.enableFilter,
|
||||||
searchKey: raw.searchKey || '',
|
searchKey: raw.searchKey || '',
|
||||||
filterBy: raw.filterBy || '',
|
filterBy: raw.filterBy || '',
|
||||||
protocolFilter: raw.protocolFilter,
|
protocolFilter: raw.protocolFilter,
|
||||||
|
inboundFilter: inb,
|
||||||
};
|
};
|
||||||
} catch {
|
} 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 [searchKey, setSearchKey] = useState(initial.searchKey);
|
||||||
const [filterBy, setFilterBy] = useState(initial.filterBy);
|
const [filterBy, setFilterBy] = useState(initial.filterBy);
|
||||||
const [protocolFilter, setProtocolFilter] = useState<string | undefined>(initial.protocolFilter);
|
const [protocolFilter, setProtocolFilter] = useState<string | undefined>(initial.protocolFilter);
|
||||||
|
const [inboundFilter, setInboundFilter] = useState<number | undefined>(initial.inboundFilter);
|
||||||
|
|
||||||
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
||||||
const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(null);
|
const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(null);
|
||||||
|
|
@ -143,9 +148,9 @@ export default function ClientsPage() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({
|
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(() => {
|
useEffect(() => {
|
||||||
const handle = window.setTimeout(() => setDebouncedSearch(searchKey), 300);
|
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
|
// 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".
|
// result set on a high page number leaves the user staring at "no clients".
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}, [debouncedSearch, enableFilter, filterBy, protocolFilter, sortColumn, sortOrder]);
|
}, [debouncedSearch, enableFilter, filterBy, protocolFilter, inboundFilter, sortColumn, sortOrder]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setQuery({
|
setQuery({
|
||||||
|
|
@ -165,10 +170,11 @@ export default function ClientsPage() {
|
||||||
search: enableFilter ? '' : debouncedSearch,
|
search: enableFilter ? '' : debouncedSearch,
|
||||||
filter: enableFilter ? (filterBy || '') : '',
|
filter: enableFilter ? (filterBy || '') : '',
|
||||||
protocol: protocolFilter || '',
|
protocol: protocolFilter || '',
|
||||||
|
inbound: inboundFilter,
|
||||||
sort: sortColumn || undefined,
|
sort: sortColumn || undefined,
|
||||||
order: sortOrder || undefined,
|
order: sortOrder || undefined,
|
||||||
});
|
});
|
||||||
}, [setQuery, currentPage, tablePageSize, enableFilter, debouncedSearch, filterBy, protocolFilter, sortColumn, sortOrder]);
|
}, [setQuery, currentPage, tablePageSize, enableFilter, debouncedSearch, filterBy, protocolFilter, inboundFilter, sortColumn, sortOrder]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pageSize > 0) {
|
if (pageSize > 0) {
|
||||||
|
|
@ -732,13 +738,37 @@ export default function ClientsPage() {
|
||||||
)}
|
)}
|
||||||
<Select
|
<Select
|
||||||
value={protocolFilter}
|
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
|
allowClear
|
||||||
placeholder={t('pages.inbounds.protocol')}
|
placeholder={t('pages.inbounds.protocol')}
|
||||||
size={isMobile ? 'small' : 'middle'}
|
size={isMobile ? 'small' : 'middle'}
|
||||||
style={{ width: 150 }}
|
style={{ width: 150 }}
|
||||||
options={protocolOptions.map((p) => ({ value: p, label: p }))}
|
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>
|
</div>
|
||||||
|
|
||||||
{!isMobile ? (
|
{!isMobile ? (
|
||||||
|
|
@ -784,6 +814,24 @@ export default function ClientsPage() {
|
||||||
<div>{t('pages.clients.empty')}</div>
|
<div>{t('pages.clients.empty')}</div>
|
||||||
</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) => {
|
{filteredClients.map((row) => {
|
||||||
const bucket = clientBucket(row);
|
const bucket = clientBucket(row);
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -828,6 +828,7 @@ type ClientPageParams struct {
|
||||||
Search string `form:"search"`
|
Search string `form:"search"`
|
||||||
Filter string `form:"filter"`
|
Filter string `form:"filter"`
|
||||||
Protocol string `form:"protocol"`
|
Protocol string `form:"protocol"`
|
||||||
|
Inbound int `form:"inbound"`
|
||||||
Sort string `form:"sort"`
|
Sort string `form:"sort"`
|
||||||
Order string `form:"order"`
|
Order string `form:"order"`
|
||||||
}
|
}
|
||||||
|
|
@ -928,6 +929,9 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
|
||||||
if params.Protocol != "" && !clientMatchesProtocol(c, params.Protocol, protocolByInbound) {
|
if params.Protocol != "" && !clientMatchesProtocol(c, params.Protocol, protocolByInbound) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if params.Inbound > 0 && !clientMatchesInbound(c, params.Inbound) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if params.Filter != "" && !clientMatchesBucket(c, params.Filter, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) {
|
if params.Filter != "" && !clientMatchesBucket(c, params.Filter, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -1046,6 +1050,18 @@ func clientMatchesProtocol(c ClientWithAttachments, protocol string, byInbound m
|
||||||
return false
|
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 {
|
func clientMatchesBucket(c ClientWithAttachments, bucket string, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) bool {
|
||||||
if bucket == "" {
|
if bucket == "" {
|
||||||
return true
|
return true
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue