diff --git a/frontend/src/hooks/useClients.ts b/frontend/src/hooks/useClients.ts index 281194ab..665fb75e 100644 --- a/frontend/src/hooks/useClients.ts +++ b/frontend/src/hooks/useClients.ts @@ -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(); diff --git a/frontend/src/pages/clients/ClientsPage.css b/frontend/src/pages/clients/ClientsPage.css index 575d68c3..ab1ce76c 100644 --- a/frontend/src/pages/clients/ClientsPage.css +++ b/frontend/src/pages/clients/ClientsPage.css @@ -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); diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx index 6657aa79..f94ce883 100644 --- a/frontend/src/pages/clients/ClientsPage.tsx +++ b/frontend/src/pages/clients/ClientsPage.tsx @@ -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(initial.protocolFilter); + const [inboundFilter, setInboundFilter] = useState(initial.inboundFilter); const [sortColumn, setSortColumn] = useState(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() { )} 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}` : ''}`, + }))} + /> {!isMobile ? ( @@ -784,6 +814,24 @@ export default function ClientsPage() {
{t('pages.clients.empty')}
)} + {filteredClients.length > 0 && ( +
+ 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); + }} + /> +
+ )} {filteredClients.map((row) => { const bucket = clientBucket(row); return ( diff --git a/web/service/client.go b/web/service/client.go index 1705aa64..a464029e 100644 --- a/web/service/client.go +++ b/web/service/client.go @@ -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