Explorar o código

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.
MHSanaei hai 19 horas
pai
achega
46bfb16445

+ 3 - 0
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();

+ 12 - 0
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);

+ 54 - 6
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<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 (

+ 16 - 0
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