mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-31 10:14:15 +00:00
feat(clients): advanced filter drawer with multi-select state/protocol/inbound + expiry/usage ranges + auto-renew/tg/comment
The old toolbar exposed a single-value Search box, a single bucket radio, and one Protocol + Inbound dropdown. Real panels with hundreds of clients across mixed protocols need to slice by combinations (active + expiring, two specific inbounds, expiring within a window, high-usage subset, etc.), which the old shape couldn't express. Backend ClientPageParams now accepts comma-separated multi values for Filter / Protocol / Inbound and three new structured fields each: expiry/usage ranges (ms / bytes), and three trinary toggles (AutoRenew / HasTgID / HasComment with on/off, yes/no). The free-text search predicate also picks up UUID / Password / Auth, which were previously invisible to search. Frontend introduces a dedicated FilterDrawer (multi-select for state/protocol/inbound, DatePicker.RangePicker for expiry, paired InputNumbers for usage, radio buttons for the trinary toggles) opened from a single Filter button with a badge for the active count. Active filters render as closable chips above the table so the user can drop them one at a time, with a Clear-all next to the Filter button. The search box stays inline and always visible.
This commit is contained in:
parent
5eb80eca8e
commit
3675f88caf
7 changed files with 642 additions and 122 deletions
|
|
@ -42,11 +42,19 @@ export interface ClientQueryParams {
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
// CSV strings — frontend joins arrays on ',', backend splits the same way.
|
||||||
filter?: string;
|
filter?: string;
|
||||||
protocol?: string;
|
protocol?: string;
|
||||||
inbound?: number;
|
inbound?: string;
|
||||||
sort?: string;
|
sort?: string;
|
||||||
order?: 'ascend' | 'descend';
|
order?: 'ascend' | 'descend';
|
||||||
|
expiryFrom?: number;
|
||||||
|
expiryTo?: number;
|
||||||
|
usageFrom?: number;
|
||||||
|
usageTo?: number;
|
||||||
|
autoRenew?: 'on' | 'off' | '';
|
||||||
|
hasTgId?: 'yes' | 'no' | '';
|
||||||
|
hasComment?: 'yes' | 'no' | '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 };
|
const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 };
|
||||||
|
|
@ -61,9 +69,16 @@ function buildQS(p: ClientQueryParams): string {
|
||||||
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.inbound) sp.set('inbound', 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);
|
||||||
|
if (p.expiryFrom && p.expiryFrom > 0) sp.set('expiryFrom', String(p.expiryFrom));
|
||||||
|
if (p.expiryTo && p.expiryTo > 0) sp.set('expiryTo', String(p.expiryTo));
|
||||||
|
if (p.usageFrom && p.usageFrom > 0) sp.set('usageFrom', String(p.usageFrom));
|
||||||
|
if (p.usageTo && p.usageTo > 0) sp.set('usageTo', String(p.usageTo));
|
||||||
|
if (p.autoRenew) sp.set('autoRenew', p.autoRenew);
|
||||||
|
if (p.hasTgId) sp.set('hasTgId', p.hasTgId);
|
||||||
|
if (p.hasComment) sp.set('hasComment', p.hasComment);
|
||||||
return sp.toString();
|
return sp.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -105,9 +120,16 @@ 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.inbound ?? '') === (next.inbound ?? '')
|
||||||
&& (prev.sort ?? '') === (next.sort ?? '')
|
&& (prev.sort ?? '') === (next.sort ?? '')
|
||||||
&& (prev.order ?? '') === (next.order ?? '')
|
&& (prev.order ?? '') === (next.order ?? '')
|
||||||
|
&& (prev.expiryFrom ?? 0) === (next.expiryFrom ?? 0)
|
||||||
|
&& (prev.expiryTo ?? 0) === (next.expiryTo ?? 0)
|
||||||
|
&& (prev.usageFrom ?? 0) === (next.usageFrom ?? 0)
|
||||||
|
&& (prev.usageTo ?? 0) === (next.usageTo ?? 0)
|
||||||
|
&& (prev.autoRenew ?? '') === (next.autoRenew ?? '')
|
||||||
|
&& (prev.hasTgId ?? '') === (next.hasTgId ?? '')
|
||||||
|
&& (prev.hasComment ?? '') === (next.hasComment ?? '')
|
||||||
) return prev;
|
) return prev;
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,20 @@
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--ant-color-fill-quaternary);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chips .ant-tag {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.dot {
|
.dot {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 8px;
|
width: 8px;
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,7 @@ import {
|
||||||
Modal,
|
Modal,
|
||||||
Pagination,
|
Pagination,
|
||||||
Popover,
|
Popover,
|
||||||
Radio,
|
|
||||||
Row,
|
Row,
|
||||||
Select,
|
|
||||||
Space,
|
Space,
|
||||||
Spin,
|
Spin,
|
||||||
Statistic,
|
Statistic,
|
||||||
|
|
@ -58,18 +56,18 @@ const ClientInfoModal = lazy(() => import('./ClientInfoModal'));
|
||||||
const ClientQrModal = lazy(() => import('./ClientQrModal'));
|
const ClientQrModal = lazy(() => import('./ClientQrModal'));
|
||||||
const ClientBulkAddModal = lazy(() => import('./ClientBulkAddModal'));
|
const ClientBulkAddModal = lazy(() => import('./ClientBulkAddModal'));
|
||||||
const ClientBulkAdjustModal = lazy(() => import('./ClientBulkAdjustModal'));
|
const ClientBulkAdjustModal = lazy(() => import('./ClientBulkAdjustModal'));
|
||||||
|
const FilterDrawer = lazy(() => import('./FilterDrawer'));
|
||||||
|
import { emptyFilters, activeFilterCount } from './filters';
|
||||||
|
import type { ClientFilters } from './filters';
|
||||||
import './ClientsPage.css';
|
import './ClientsPage.css';
|
||||||
|
|
||||||
const FILTER_STATE_KEY = 'clientsFilterState';
|
const FILTER_STATE_KEY = 'clientsFilterState';
|
||||||
|
|
||||||
type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
|
type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
|
||||||
|
|
||||||
interface FilterState {
|
interface PersistedFilterState {
|
||||||
enableFilter: boolean;
|
|
||||||
searchKey: string;
|
searchKey: string;
|
||||||
filterBy: string;
|
filters: ClientFilters;
|
||||||
protocolFilter?: string;
|
|
||||||
inboundFilter?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
|
const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
|
||||||
|
|
@ -86,22 +84,30 @@ const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
|
||||||
};
|
};
|
||||||
const INBOUND_CHIP_LIMIT = 1;
|
const INBOUND_CHIP_LIMIT = 1;
|
||||||
|
|
||||||
function readFilterState(): FilterState {
|
function readFilterState(): PersistedFilterState {
|
||||||
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;
|
const fromRaw = (raw.filters ?? {}) as Partial<ClientFilters>;
|
||||||
return {
|
return {
|
||||||
enableFilter: !!raw.enableFilter,
|
searchKey: typeof raw.searchKey === 'string' ? raw.searchKey : '',
|
||||||
searchKey: raw.searchKey || '',
|
filters: {
|
||||||
filterBy: raw.filterBy || '',
|
...emptyFilters(),
|
||||||
protocolFilter: raw.protocolFilter,
|
...fromRaw,
|
||||||
inboundFilter: inb,
|
buckets: Array.isArray(fromRaw.buckets) ? fromRaw.buckets : [],
|
||||||
|
protocols: Array.isArray(fromRaw.protocols) ? fromRaw.protocols : [],
|
||||||
|
inboundIds: Array.isArray(fromRaw.inboundIds) ? fromRaw.inboundIds : [],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { enableFilter: false, searchKey: '', filterBy: '', protocolFilter: undefined, inboundFilter: undefined };
|
return { searchKey: '', filters: emptyFilters() };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function gbToBytes(gb: number | undefined): number {
|
||||||
|
if (!gb || gb <= 0) return 0;
|
||||||
|
return Math.round(gb * 1024 * 1024 * 1024);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ClientsPage() {
|
export default function ClientsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||||
|
|
@ -142,11 +148,9 @@ export default function ClientsPage() {
|
||||||
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
||||||
|
|
||||||
const initial = readFilterState();
|
const initial = readFilterState();
|
||||||
const [enableFilter, setEnableFilter] = useState(initial.enableFilter);
|
|
||||||
const [searchKey, setSearchKey] = useState(initial.searchKey);
|
const [searchKey, setSearchKey] = useState(initial.searchKey);
|
||||||
const [filterBy, setFilterBy] = useState(initial.filterBy);
|
const [filters, setFilters] = useState<ClientFilters>(initial.filters);
|
||||||
const [protocolFilter, setProtocolFilter] = useState<string | undefined>(initial.protocolFilter);
|
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
|
||||||
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);
|
||||||
|
|
@ -157,10 +161,8 @@ export default function ClientsPage() {
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState(searchKey);
|
const [debouncedSearch, setDebouncedSearch] = useState(searchKey);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({
|
localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({ searchKey, filters }));
|
||||||
enableFilter, searchKey, filterBy, protocolFilter, inboundFilter,
|
}, [searchKey, filters]);
|
||||||
}));
|
|
||||||
}, [enableFilter, searchKey, filterBy, protocolFilter, inboundFilter]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handle = window.setTimeout(() => setDebouncedSearch(searchKey), 300);
|
const handle = window.setTimeout(() => setDebouncedSearch(searchKey), 300);
|
||||||
|
|
@ -171,20 +173,29 @@ 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, inboundFilter, sortColumn, sortOrder]);
|
}, [debouncedSearch, filters, sortColumn, sortOrder]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setQuery({
|
setQuery({
|
||||||
page: currentPage,
|
page: currentPage,
|
||||||
pageSize: tablePageSize,
|
pageSize: tablePageSize,
|
||||||
search: enableFilter ? '' : debouncedSearch,
|
search: debouncedSearch,
|
||||||
filter: enableFilter ? (filterBy || '') : '',
|
filter: filters.buckets.join(','),
|
||||||
protocol: protocolFilter || '',
|
protocol: filters.protocols.join(','),
|
||||||
inbound: inboundFilter,
|
inbound: filters.inboundIds.join(','),
|
||||||
|
expiryFrom: filters.expiryFrom,
|
||||||
|
expiryTo: filters.expiryTo,
|
||||||
|
usageFrom: gbToBytes(filters.usageFromGB),
|
||||||
|
usageTo: gbToBytes(filters.usageToGB),
|
||||||
|
autoRenew: filters.autoRenew || undefined,
|
||||||
|
hasTgId: filters.hasTgId || undefined,
|
||||||
|
hasComment: filters.hasComment || undefined,
|
||||||
sort: sortColumn || undefined,
|
sort: sortColumn || undefined,
|
||||||
order: sortOrder || undefined,
|
order: sortOrder || undefined,
|
||||||
});
|
});
|
||||||
}, [setQuery, currentPage, tablePageSize, enableFilter, debouncedSearch, filterBy, protocolFilter, inboundFilter, sortColumn, sortOrder]);
|
}, [setQuery, currentPage, tablePageSize, debouncedSearch, filters, sortColumn, sortOrder]);
|
||||||
|
|
||||||
|
const activeCount = activeFilterCount(filters);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pageSize > 0) {
|
if (pageSize > 0) {
|
||||||
|
|
@ -640,10 +651,16 @@ export default function ClientsPage() {
|
||||||
const allSelected = filteredClients.length > 0 && selectedRowKeys.length === filteredClients.length;
|
const allSelected = filteredClients.length > 0 && selectedRowKeys.length === filteredClients.length;
|
||||||
const someSelected = selectedRowKeys.length > 0 && selectedRowKeys.length < filteredClients.length;
|
const someSelected = selectedRowKeys.length > 0 && selectedRowKeys.length < filteredClients.length;
|
||||||
|
|
||||||
function onToggleFilter(checked: boolean) {
|
function clearOneFilter<K extends keyof ClientFilters>(key: K) {
|
||||||
setEnableFilter(checked);
|
if (key === 'expiryFrom' || key === 'expiryTo') {
|
||||||
if (checked) setSearchKey('');
|
setFilters({ ...filters, expiryFrom: undefined, expiryTo: undefined });
|
||||||
else setFilterBy('');
|
return;
|
||||||
|
}
|
||||||
|
if (key === 'usageFromGB' || key === 'usageToGB') {
|
||||||
|
setFilters({ ...filters, usageFromGB: undefined, usageToGB: undefined });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFilters({ ...filters, [key]: emptyFilters()[key] });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -741,72 +758,96 @@ export default function ClientsPage() {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className={isMobile ? 'filter-bar mobile' : 'filter-bar'}>
|
<div className={isMobile ? 'filter-bar mobile' : 'filter-bar'}>
|
||||||
<Switch
|
<Input
|
||||||
checked={enableFilter}
|
value={searchKey}
|
||||||
onChange={onToggleFilter}
|
onChange={(e) => setSearchKey(e.target.value)}
|
||||||
checkedChildren={<SearchOutlined />}
|
placeholder={t('pages.clients.searchPlaceholder')}
|
||||||
unCheckedChildren={<FilterOutlined />}
|
allowClear
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
size={isMobile ? 'small' : 'middle'}
|
||||||
|
style={{ maxWidth: 320 }}
|
||||||
/>
|
/>
|
||||||
{!enableFilter && (
|
<Badge count={activeCount} size="small" offset={[-4, 4]}>
|
||||||
<Input
|
<Button
|
||||||
value={searchKey}
|
icon={<FilterOutlined />}
|
||||||
onChange={(e) => setSearchKey(e.target.value)}
|
|
||||||
placeholder={t('search')}
|
|
||||||
autoFocus
|
|
||||||
size={isMobile ? 'small' : 'middle'}
|
|
||||||
style={{ maxWidth: 300 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{enableFilter && (
|
|
||||||
<Radio.Group
|
|
||||||
value={filterBy}
|
|
||||||
onChange={(e) => setFilterBy(e.target.value)}
|
|
||||||
optionType="button"
|
|
||||||
buttonStyle="solid"
|
|
||||||
size={isMobile ? 'small' : 'middle'}
|
size={isMobile ? 'small' : 'middle'}
|
||||||
|
onClick={() => setFilterDrawerOpen(true)}
|
||||||
|
type={activeCount > 0 ? 'primary' : 'default'}
|
||||||
>
|
>
|
||||||
<Radio.Button value="">{t('none')}</Radio.Button>
|
{!isMobile && t('filter')}
|
||||||
<Radio.Button value="active">{t('subscription.active')}</Radio.Button>
|
</Button>
|
||||||
<Radio.Button value="deactive">{t('disabled')}</Radio.Button>
|
</Badge>
|
||||||
<Radio.Button value="depleted">{t('depleted')}</Radio.Button>
|
{activeCount > 0 && (
|
||||||
<Radio.Button value="expiring">{t('depletingSoon')}</Radio.Button>
|
<Button
|
||||||
<Radio.Button value="online">{t('online')}</Radio.Button>
|
size={isMobile ? 'small' : 'middle'}
|
||||||
</Radio.Group>
|
onClick={() => setFilters(emptyFilters())}
|
||||||
|
>
|
||||||
|
{t('pages.clients.clearAllFilters')}
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Select
|
|
||||||
value={protocolFilter}
|
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<div className="filter-chips">
|
||||||
|
{filters.buckets.map((b) => (
|
||||||
|
<Tag
|
||||||
|
key={`b-${b}`}
|
||||||
|
closable
|
||||||
|
onClose={() => setFilters({ ...filters, buckets: filters.buckets.filter((x) => x !== b) })}
|
||||||
|
>
|
||||||
|
{bucketChipLabel(b, t)}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
{filters.protocols.map((p) => (
|
||||||
|
<Tag
|
||||||
|
key={`p-${p}`}
|
||||||
|
closable
|
||||||
|
color="blue"
|
||||||
|
onClose={() => setFilters({ ...filters, protocols: filters.protocols.filter((x) => x !== p) })}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
{filters.inboundIds.map((id) => (
|
||||||
|
<Tag
|
||||||
|
key={`i-${id}`}
|
||||||
|
closable
|
||||||
|
color="cyan"
|
||||||
|
onClose={() => setFilters({ ...filters, inboundIds: filters.inboundIds.filter((x) => x !== id) })}
|
||||||
|
>
|
||||||
|
{inboundLabel(id)}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
{(filters.expiryFrom || filters.expiryTo) && (
|
||||||
|
<Tag closable color="purple" onClose={() => clearOneFilter('expiryFrom')}>
|
||||||
|
{t('pages.clients.expiryTime')}: {filters.expiryFrom ? IntlUtil.formatDate(filters.expiryFrom, datepicker) : '…'}
|
||||||
|
{' → '}
|
||||||
|
{filters.expiryTo ? IntlUtil.formatDate(filters.expiryTo, datepicker) : '…'}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
{(filters.usageFromGB || filters.usageToGB) && (
|
||||||
|
<Tag closable color="orange" onClose={() => clearOneFilter('usageFromGB')}>
|
||||||
|
{t('pages.clients.traffic')}: {filters.usageFromGB ?? 0}{filters.usageToGB ? `–${filters.usageToGB}` : '+'} GB
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
{filters.autoRenew && (
|
||||||
|
<Tag closable color="gold" onClose={() => clearOneFilter('autoRenew')}>
|
||||||
|
{t('pages.clients.renew')}: {filters.autoRenew === 'on' ? t('enabled') : t('disabled')}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
{filters.hasTgId && (
|
||||||
|
<Tag closable onClose={() => clearOneFilter('hasTgId')}>
|
||||||
|
{t('pages.clients.telegramId')}: {filters.hasTgId === 'yes' ? t('pages.clients.has') : t('pages.clients.hasNot')}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
{filters.hasComment && (
|
||||||
|
<Tag closable onClose={() => clearOneFilter('hasComment')}>
|
||||||
|
{t('pages.clients.comment')}: {filters.hasComment === 'yes' ? t('pages.clients.has') : t('pages.clients.hasNot')}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isMobile ? (
|
{!isMobile ? (
|
||||||
<Table<ClientRecord>
|
<Table<ClientRecord>
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
|
@ -993,7 +1034,28 @@ export default function ClientsPage() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</LazyMount>
|
</LazyMount>
|
||||||
|
<LazyMount when={filterDrawerOpen}>
|
||||||
|
<FilterDrawer
|
||||||
|
open={filterDrawerOpen}
|
||||||
|
onOpenChange={setFilterDrawerOpen}
|
||||||
|
filters={filters}
|
||||||
|
onChange={setFilters}
|
||||||
|
inbounds={inbounds}
|
||||||
|
protocols={protocolOptions}
|
||||||
|
/>
|
||||||
|
</LazyMount>
|
||||||
</Layout>
|
</Layout>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bucketChipLabel(b: string, t: (k: string) => string): string {
|
||||||
|
switch (b) {
|
||||||
|
case 'active': return t('subscription.active');
|
||||||
|
case 'expiring': return t('depletingSoon');
|
||||||
|
case 'depleted': return t('depleted');
|
||||||
|
case 'deactive': return t('disabled');
|
||||||
|
case 'online': return t('online');
|
||||||
|
default: return b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
222
frontend/src/pages/clients/FilterDrawer.tsx
Normal file
222
frontend/src/pages/clients/FilterDrawer.tsx
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Col,
|
||||||
|
DatePicker,
|
||||||
|
Drawer,
|
||||||
|
Form,
|
||||||
|
InputNumber,
|
||||||
|
Radio,
|
||||||
|
Row,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
|
||||||
|
import type { InboundOption } from '@/hooks/useClients';
|
||||||
|
import { emptyFilters, type ClientFilters } from './filters';
|
||||||
|
|
||||||
|
interface FilterDrawerProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
filters: ClientFilters;
|
||||||
|
onChange: (next: ClientFilters) => void;
|
||||||
|
inbounds: InboundOption[];
|
||||||
|
protocols: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUCKET_KEYS = ['active', 'expiring', 'depleted', 'deactive', 'online'] as const;
|
||||||
|
|
||||||
|
export default function FilterDrawer({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
filters,
|
||||||
|
onChange,
|
||||||
|
inbounds,
|
||||||
|
protocols,
|
||||||
|
}: FilterDrawerProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
function patch<K extends keyof ClientFilters>(key: K, value: ClientFilters[K]) {
|
||||||
|
onChange({ ...filters, [key]: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
const inboundOptions = useMemo(
|
||||||
|
() => inbounds.map((ib) => ({
|
||||||
|
value: ib.id,
|
||||||
|
label: ib.remark
|
||||||
|
? `${ib.remark} (${ib.protocol || ''}${ib.port ? `:${ib.port}` : ''})`
|
||||||
|
: `#${ib.id} ${ib.protocol || ''}${ib.port ? `:${ib.port}` : ''}`,
|
||||||
|
})),
|
||||||
|
[inbounds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const protocolOptions = useMemo(
|
||||||
|
() => protocols.map((p) => ({ value: p, label: p })),
|
||||||
|
[protocols],
|
||||||
|
);
|
||||||
|
|
||||||
|
const dateRange: [Dayjs | null, Dayjs | null] = [
|
||||||
|
filters.expiryFrom ? dayjs(filters.expiryFrom) : null,
|
||||||
|
filters.expiryTo ? dayjs(filters.expiryTo) : null,
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title={t('pages.clients.filterTitle')}
|
||||||
|
open={open}
|
||||||
|
onClose={() => onOpenChange(false)}
|
||||||
|
width={420}
|
||||||
|
destroyOnHidden
|
||||||
|
footer={
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<Button onClick={() => onChange(emptyFilters())} danger>
|
||||||
|
{t('pages.clients.clearAllFilters')}
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" onClick={() => onOpenChange(false)}>
|
||||||
|
{t('done')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Form layout="vertical">
|
||||||
|
<Form.Item label={<Typography.Text strong>{t('status')}</Typography.Text>}>
|
||||||
|
<Checkbox.Group
|
||||||
|
value={filters.buckets}
|
||||||
|
onChange={(v) => patch('buckets', v as string[])}
|
||||||
|
>
|
||||||
|
<Space direction="vertical">
|
||||||
|
{BUCKET_KEYS.map((k) => (
|
||||||
|
<Checkbox key={k} value={k}>
|
||||||
|
{bucketLabel(k, t)}
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Checkbox.Group>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label={t('pages.inbounds.protocol')}>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
value={filters.protocols}
|
||||||
|
onChange={(v) => patch('protocols', v as string[])}
|
||||||
|
options={protocolOptions}
|
||||||
|
placeholder={t('pages.inbounds.protocol')}
|
||||||
|
maxTagCount="responsive"
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label={t('inbounds')}>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
value={filters.inboundIds}
|
||||||
|
onChange={(v) => patch('inboundIds', v as number[])}
|
||||||
|
options={inboundOptions}
|
||||||
|
placeholder={t('inbounds')}
|
||||||
|
maxTagCount="responsive"
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
listHeight={220}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label={t('pages.clients.expiryTime')}>
|
||||||
|
<DatePicker.RangePicker
|
||||||
|
value={dateRange}
|
||||||
|
onChange={(range) => {
|
||||||
|
const from = range?.[0]?.startOf('day').valueOf();
|
||||||
|
const to = range?.[1]?.endOf('day').valueOf();
|
||||||
|
onChange({ ...filters, expiryFrom: from || undefined, expiryTo: to || undefined });
|
||||||
|
}}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
allowEmpty={[true, true]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label={`${t('pages.clients.traffic')} (GB)`}>
|
||||||
|
<Row gutter={8}>
|
||||||
|
<Col span={12}>
|
||||||
|
<InputNumber
|
||||||
|
value={filters.usageFromGB}
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
placeholder={t('from')}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
onChange={(v) => patch('usageFromGB', typeof v === 'number' ? v : undefined)}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<InputNumber
|
||||||
|
value={filters.usageToGB}
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
placeholder={t('to')}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
onChange={(v) => patch('usageToGB', typeof v === 'number' ? v : undefined)}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label={t('pages.clients.renew')}>
|
||||||
|
<Radio.Group
|
||||||
|
value={filters.autoRenew}
|
||||||
|
onChange={(e) => patch('autoRenew', e.target.value)}
|
||||||
|
optionType="button"
|
||||||
|
buttonStyle="solid"
|
||||||
|
options={[
|
||||||
|
{ value: '', label: t('all') },
|
||||||
|
{ value: 'on', label: t('enabled') },
|
||||||
|
{ value: 'off', label: t('disabled') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label={t('pages.clients.telegramId')}>
|
||||||
|
<Radio.Group
|
||||||
|
value={filters.hasTgId}
|
||||||
|
onChange={(e) => patch('hasTgId', e.target.value)}
|
||||||
|
optionType="button"
|
||||||
|
buttonStyle="solid"
|
||||||
|
options={[
|
||||||
|
{ value: '', label: t('all') },
|
||||||
|
{ value: 'yes', label: t('pages.clients.has') },
|
||||||
|
{ value: 'no', label: t('pages.clients.hasNot') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label={t('pages.clients.comment')}>
|
||||||
|
<Radio.Group
|
||||||
|
value={filters.hasComment}
|
||||||
|
onChange={(e) => patch('hasComment', e.target.value)}
|
||||||
|
optionType="button"
|
||||||
|
buttonStyle="solid"
|
||||||
|
options={[
|
||||||
|
{ value: '', label: t('all') },
|
||||||
|
{ value: 'yes', label: t('pages.clients.has') },
|
||||||
|
{ value: 'no', label: t('pages.clients.hasNot') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bucketLabel(key: string, t: (k: string) => string): string {
|
||||||
|
switch (key) {
|
||||||
|
case 'active': return t('subscription.active');
|
||||||
|
case 'expiring': return t('depletingSoon');
|
||||||
|
case 'depleted': return t('depleted');
|
||||||
|
case 'deactive': return t('disabled');
|
||||||
|
case 'online': return t('online');
|
||||||
|
default: return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
frontend/src/pages/clients/filters.ts
Normal file
36
frontend/src/pages/clients/filters.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
export interface ClientFilters {
|
||||||
|
buckets: string[];
|
||||||
|
protocols: string[];
|
||||||
|
inboundIds: number[];
|
||||||
|
expiryFrom?: number;
|
||||||
|
expiryTo?: number;
|
||||||
|
usageFromGB?: number;
|
||||||
|
usageToGB?: number;
|
||||||
|
autoRenew: '' | 'on' | 'off';
|
||||||
|
hasTgId: '' | 'yes' | 'no';
|
||||||
|
hasComment: '' | 'yes' | 'no';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyFilters(): ClientFilters {
|
||||||
|
return {
|
||||||
|
buckets: [],
|
||||||
|
protocols: [],
|
||||||
|
inboundIds: [],
|
||||||
|
autoRenew: '',
|
||||||
|
hasTgId: '',
|
||||||
|
hasComment: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activeFilterCount(f: ClientFilters): number {
|
||||||
|
let n = 0;
|
||||||
|
if (f.buckets.length) n++;
|
||||||
|
if (f.protocols.length) n++;
|
||||||
|
if (f.inboundIds.length) n++;
|
||||||
|
if (f.expiryFrom || f.expiryTo) n++;
|
||||||
|
if (f.usageFromGB || f.usageToGB) n++;
|
||||||
|
if (f.autoRenew) n++;
|
||||||
|
if (f.hasTgId) n++;
|
||||||
|
if (f.hasComment) n++;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -864,15 +865,28 @@ type ClientSlim struct {
|
||||||
|
|
||||||
// ClientPageParams are the query params accepted by /panel/api/clients/list/paged.
|
// ClientPageParams are the query params accepted by /panel/api/clients/list/paged.
|
||||||
// All fields are optional — the empty value means "no filter" / defaults.
|
// All fields are optional — the empty value means "no filter" / defaults.
|
||||||
|
//
|
||||||
|
// Filter / Protocol / Inbound accept either a single value or a comma-separated
|
||||||
|
// list; matching is OR within a field and AND across fields. The numeric range
|
||||||
|
// fields treat 0 as "unset" on the lower bound and 0 (or negative) as
|
||||||
|
// "unbounded" on the upper bound.
|
||||||
type ClientPageParams struct {
|
type ClientPageParams struct {
|
||||||
Page int `form:"page"`
|
Page int `form:"page"`
|
||||||
PageSize int `form:"pageSize"`
|
PageSize int `form:"pageSize"`
|
||||||
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"`
|
Inbound string `form:"inbound"`
|
||||||
Sort string `form:"sort"`
|
Sort string `form:"sort"`
|
||||||
Order string `form:"order"`
|
Order string `form:"order"`
|
||||||
|
|
||||||
|
ExpiryFrom int64 `form:"expiryFrom"`
|
||||||
|
ExpiryTo int64 `form:"expiryTo"`
|
||||||
|
UsageFrom int64 `form:"usageFrom"`
|
||||||
|
UsageTo int64 `form:"usageTo"`
|
||||||
|
AutoRenew string `form:"autoRenew"`
|
||||||
|
HasTgID string `form:"hasTgId"`
|
||||||
|
HasComment string `form:"hasComment"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClientPageResponse is the shape returned by ListPaged. `Total` is the
|
// ClientPageResponse is the shape returned by ListPaged. `Total` is the
|
||||||
|
|
@ -931,8 +945,12 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
|
||||||
page = 1
|
page = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protocols := parseCSVStrings(params.Protocol)
|
||||||
|
inboundIDs := parseCSVInts(params.Inbound)
|
||||||
|
buckets := parseCSVStrings(params.Filter)
|
||||||
|
|
||||||
var protocolByInbound map[int]string
|
var protocolByInbound map[int]string
|
||||||
if params.Protocol != "" {
|
if len(protocols) > 0 {
|
||||||
inbounds, err := inboundSvc.GetAllInbounds()
|
inbounds, err := inboundSvc.GetAllInbounds()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
protocolByInbound = make(map[int]string, len(inbounds))
|
protocolByInbound = make(map[int]string, len(inbounds))
|
||||||
|
|
@ -968,13 +986,28 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
|
||||||
if needle != "" && !clientMatchesSearch(c, needle) {
|
if needle != "" && !clientMatchesSearch(c, needle) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if params.Protocol != "" && !clientMatchesProtocol(c, params.Protocol, protocolByInbound) {
|
if len(protocols) > 0 && !clientMatchesAnyProtocol(c, protocols, protocolByInbound) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if params.Inbound > 0 && !clientMatchesInbound(c, params.Inbound) {
|
if len(inboundIDs) > 0 && !clientMatchesAnyInbound(c, inboundIDs) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if params.Filter != "" && !clientMatchesBucket(c, params.Filter, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) {
|
if len(buckets) > 0 && !clientMatchesAnyBucket(c, buckets, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !clientMatchesExpiryRange(c, params.ExpiryFrom, params.ExpiryTo) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !clientMatchesUsageRange(c, params.UsageFrom, params.UsageTo) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !clientMatchesAutoRenew(c, params.AutoRenew) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !clientMatchesHasTgID(c, params.HasTgID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !clientMatchesHasComment(c, params.HasComment) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
filtered = append(filtered, c)
|
filtered = append(filtered, c)
|
||||||
|
|
@ -1068,35 +1101,157 @@ func clientMatchesSearch(c ClientWithAttachments, needle string) bool {
|
||||||
if needle == "" {
|
if needle == "" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if strings.Contains(strings.ToLower(c.Email), needle) {
|
candidates := [...]string{c.Email, c.SubID, c.Comment, c.UUID, c.Password, c.Auth}
|
||||||
return true
|
for _, v := range candidates {
|
||||||
}
|
if v != "" && strings.Contains(strings.ToLower(v), needle) {
|
||||||
if strings.Contains(strings.ToLower(c.SubID), needle) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if strings.Contains(strings.ToLower(c.Comment), needle) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func clientMatchesProtocol(c ClientWithAttachments, protocol string, byInbound map[int]string) bool {
|
|
||||||
if protocol == "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for _, id := range c.InboundIds {
|
|
||||||
if byInbound[id] == protocol {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func clientMatchesInbound(c ClientWithAttachments, inboundId int) bool {
|
// parseCSVStrings splits a comma-separated list, trims/lower-cases each item,
|
||||||
if inboundId <= 0 {
|
// and drops blanks. Returns nil when the input has no usable entries — the
|
||||||
|
// caller can then skip the predicate entirely.
|
||||||
|
func parseCSVStrings(raw string) []string {
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(raw, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
s := strings.ToLower(strings.TrimSpace(p))
|
||||||
|
if s != "" {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCSVInts is parseCSVStrings for positive integer IDs; non-numeric or
|
||||||
|
// non-positive entries are silently dropped.
|
||||||
|
func parseCSVInts(raw string) []int {
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(raw, ",")
|
||||||
|
out := make([]int, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
s := strings.TrimSpace(p)
|
||||||
|
if s == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n, err := strconv.Atoi(s); err == nil && n > 0 {
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientMatchesAnyProtocol(c ClientWithAttachments, protocols []string, byInbound map[int]string) bool {
|
||||||
|
for _, id := range c.InboundIds {
|
||||||
|
p := byInbound[id]
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if slices.Contains(protocols, strings.ToLower(p)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientMatchesAnyInbound(c ClientWithAttachments, inboundIds []int) bool {
|
||||||
|
for _, id := range c.InboundIds {
|
||||||
|
if slices.Contains(inboundIds, id) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientMatchesAnyBucket(c ClientWithAttachments, buckets []string, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) bool {
|
||||||
|
for _, b := range buckets {
|
||||||
|
if clientMatchesBucket(c, b, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientMatchesExpiryRange(c ClientWithAttachments, fromMs, toMs int64) bool {
|
||||||
|
if fromMs <= 0 && toMs <= 0 {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return slices.Contains(c.InboundIds, inboundId)
|
// expiryTime of 0 means "never expires"; treat it as outside any bounded
|
||||||
|
// range so users filtering by date see only clients with concrete expiries.
|
||||||
|
if c.ExpiryTime == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Negative expiry is the "delayed start" sentinel; same treatment as never.
|
||||||
|
if c.ExpiryTime < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if fromMs > 0 && c.ExpiryTime < fromMs {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if toMs > 0 && c.ExpiryTime > toMs {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientMatchesUsageRange(c ClientWithAttachments, fromBytes, toBytes int64) bool {
|
||||||
|
if fromBytes <= 0 && toBytes <= 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
used := int64(0)
|
||||||
|
if c.Traffic != nil {
|
||||||
|
used = c.Traffic.Up + c.Traffic.Down
|
||||||
|
}
|
||||||
|
if fromBytes > 0 && used < fromBytes {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if toBytes > 0 && used > toBytes {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientMatchesAutoRenew(c ClientWithAttachments, mode string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||||
|
case "on":
|
||||||
|
return c.Reset > 0
|
||||||
|
case "off":
|
||||||
|
return c.Reset <= 0
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientMatchesHasTgID(c ClientWithAttachments, mode string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||||
|
case "yes":
|
||||||
|
return c.TgID != 0
|
||||||
|
case "no":
|
||||||
|
return c.TgID == 0
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientMatchesHasComment(c ClientWithAttachments, mode string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||||
|
case "yes":
|
||||||
|
return strings.TrimSpace(c.Comment) != ""
|
||||||
|
case "no":
|
||||||
|
return strings.TrimSpace(c.Comment) == ""
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,10 @@
|
||||||
"protocol": "Protocol",
|
"protocol": "Protocol",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"filter": "Filter",
|
"filter": "Filter",
|
||||||
|
"all": "All",
|
||||||
|
"from": "From",
|
||||||
|
"to": "To",
|
||||||
|
"done": "Done",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
|
|
@ -454,6 +458,11 @@
|
||||||
"days": "Day(s)",
|
"days": "Day(s)",
|
||||||
"renew": "Auto Renew",
|
"renew": "Auto Renew",
|
||||||
"renewDesc": "Auto-renewal after expiration. (0 = disable)(unit: day)",
|
"renewDesc": "Auto-renewal after expiration. (0 = disable)(unit: day)",
|
||||||
|
"searchPlaceholder": "Search email, comment, sub ID, UUID, password, auth…",
|
||||||
|
"filterTitle": "Filter clients",
|
||||||
|
"clearAllFilters": "Clear all",
|
||||||
|
"has": "Has",
|
||||||
|
"hasNot": "Doesn't have",
|
||||||
"title": "Clients",
|
"title": "Clients",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"totalGB": "Total Sent/Received (GB)",
|
"totalGB": "Total Sent/Received (GB)",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue