feat(clients,inbounds): move search/filter to Clients page + small fixes

Search/filter relocation:
- Remove the search/filter toolbar (search switch + filter radio +
  protocol/node selects + the visibleInbounds projection +
  inboundsFilterState localStorage + filter CSS + the SearchOutlined/
  FilterOutlined/ObjectUtil/Inbound imports it required) from
  InboundList. The filters were all client-oriented buckets bolted
  onto the inbound row.
- Add a search/filter toolbar to ClientsPage with the same shape:
  switch between deep-text search and bucket filter (active /
  deactive / depleted / expiring / online) + protocol filter that
  matches clients attached to at least one inbound with the chosen
  protocol. State persists in clientsFilterState localStorage.
  filteredClients drives both the desktop table and the mobile card
  list, and select-all / allSelected / someSelected only span the
  visible subset.
- useClients now also fetches expireDiff and trafficDiff from
  /panel/setting/defaultSettings (used to detect the expiring
  bucket); ClientsPage threads them into the client-bucket helper.

Loose fixes folded in:
- Add Client: email field is auto-filled with a random handle on
  open, matching uuid/subId/password/auth.
- Inbound clone: parse and reuse the source settings JSON (with
  clients reset to []) instead of building a fresh defaulted
  Settings, so VLESS Encryption/Decryption and other non-client
  fields survive the clone.
- en-US.json: add the ipLog string used by the edit-client modal.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MHSanaei 2026-05-17 12:37:05 +02:00
parent 418acf8cfa
commit e9fce827ac
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
6 changed files with 153 additions and 184 deletions

View file

@ -310,7 +310,7 @@ async function onSubmit() {
<span style="margin-left: 8px">{{ t('enable') }}</span>
</a-form-item>
<a-form-item v-if="isEdit" :label="t('pages.inbounds.ipLog') || 'IP Log'">
<a-form-item v-if="isEdit" :label="t('pages.clients.ipLog') || 'IP Log'">
<a-space style="margin-bottom: 8px">
<a-button size="small" :loading="ipsLoading" @click="loadIps">{{ t('refresh') }}</a-button>
<a-button size="small" danger :loading="ipsClearing" :disabled="clientIps.length === 0" @click="clearIps">

View file

@ -1,5 +1,5 @@
<script setup>
import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Modal, message } from 'ant-design-vue';
import {
@ -13,12 +13,14 @@ import {
RestOutlined,
MoreOutlined,
UsergroupAddOutlined,
SearchOutlined,
FilterOutlined,
} from '@ant-design/icons-vue';
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
import { useMediaQuery } from '@/composables/useMediaQuery.js';
import AppSidebar from '@/components/AppSidebar.vue';
import { SizeFormatter, IntlUtil } from '@/utils';
import { ObjectUtil, SizeFormatter, IntlUtil } from '@/utils';
import { useClients } from './useClients.js';
import ClientFormModal from './ClientFormModal.vue';
import ClientInfoModal from './ClientInfoModal.vue';
@ -35,6 +37,8 @@ const {
fetched,
subSettings,
ipLimitEnable,
expireDiff,
trafficDiff,
create,
update,
remove,
@ -95,15 +99,15 @@ function isSelected(id) {
}
function selectAll(checked) {
selectedRowKeys.value = checked ? clients.value.map((c) => c.id) : [];
selectedRowKeys.value = checked ? filteredClients.value.map((c) => c.id) : [];
}
const allSelected = computed(
() => clients.value.length > 0 && selectedRowKeys.value.length === clients.value.length,
() => filteredClients.value.length > 0 && selectedRowKeys.value.length === filteredClients.value.length,
);
const someSelected = computed(
() => selectedRowKeys.value.length > 0 && selectedRowKeys.value.length < clients.value.length,
() => selectedRowKeys.value.length > 0 && selectedRowKeys.value.length < filteredClients.value.length,
);
function onBulkAdd() {
@ -162,6 +166,35 @@ function onDelDepleted() {
});
}
const FILTER_STATE_KEY = 'clientsFilterState';
const savedFilterState = (() => {
try { return JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}'); }
catch (_e) { return {}; }
})();
const enableFilter = ref(!!savedFilterState.enableFilter);
const searchKey = ref(savedFilterState.searchKey || '');
const filterBy = ref(savedFilterState.filterBy || '');
const protocolFilter = ref(savedFilterState.protocolFilter || undefined);
watch([enableFilter, searchKey, filterBy, protocolFilter], () => {
localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({
enableFilter: enableFilter.value,
searchKey: searchKey.value,
filterBy: filterBy.value,
protocolFilter: protocolFilter.value,
}));
});
function onToggleFilter() {
if (enableFilter.value) searchKey.value = '';
else filterBy.value = '';
}
const protocolOptions = computed(() => {
const values = new Set((inbounds.value || []).map((i) => i.protocol).filter(Boolean));
return [...values].sort();
});
const onlineSet = computed(() => new Set(onlines.value || []));
const inboundsById = computed(() => {
const out = {};
@ -179,6 +212,49 @@ function inboundLabel(id) {
return ib.remark ? `${ib.remark} (${ib.protocol}:${ib.port})` : `${ib.protocol}:${ib.port}`;
}
function clientBucket(row) {
if (!row) return null;
if (!row.enable) return 'deactive';
const traffic = row.traffic || {};
const used = (traffic.up || 0) + (traffic.down || 0);
const total = row.totalGB || 0;
const now = Date.now();
const expired = row.expiryTime > 0 && row.expiryTime <= now;
const exhausted = total > 0 && used >= total;
if (expired || exhausted) return 'depleted';
const nearExpiry = row.expiryTime > 0 && row.expiryTime - now < (expireDiff.value || 0);
const nearLimit = total > 0 && total - used < (trafficDiff.value || 0);
if (nearExpiry || nearLimit) return 'expiring';
return 'active';
}
function clientMatchesProtocol(row, protocol) {
if (!protocol) return true;
const ids = Array.isArray(row.inboundIds) ? row.inboundIds : [];
for (const id of ids) {
const ib = inboundsById.value[id];
if (ib && ib.protocol === protocol) return true;
}
return false;
}
const filteredClients = computed(() => {
let rows = clients.value || [];
if (enableFilter.value) {
if (filterBy.value === 'online') {
rows = rows.filter((r) => r.enable && isOnline(r.email));
} else if (filterBy.value) {
rows = rows.filter((r) => clientBucket(r) === filterBy.value);
}
} else if (!ObjectUtil.isEmpty(searchKey.value)) {
rows = rows.filter((r) => ObjectUtil.deepSearch(r, searchKey.value));
}
if (protocolFilter.value) {
rows = rows.filter((r) => clientMatchesProtocol(r, protocolFilter.value));
}
return rows;
});
function onAdd() {
formMode.value = 'add';
editingClient.value = null;
@ -374,7 +450,35 @@ const columns = computed(() => [
</div>
</template>
<a-table v-if="!isMobile" :columns="columns" :data-source="clients" :loading="loading" row-key="id"
<div :class="isMobile ? 'filter-bar mobile' : 'filter-bar'">
<a-switch v-model:checked="enableFilter" @change="onToggleFilter">
<template #checkedChildren>
<SearchOutlined />
</template>
<template #unCheckedChildren>
<FilterOutlined />
</template>
</a-switch>
<a-input v-if="!enableFilter" v-model:value="searchKey" :placeholder="t('search')" autofocus
:size="isMobile ? 'small' : 'middle'" :style="{ maxWidth: '300px' }" />
<a-radio-group v-if="enableFilter" v-model:value="filterBy" button-style="solid"
:size="isMobile ? 'small' : 'middle'">
<a-radio-button value="">{{ t('none') }}</a-radio-button>
<a-radio-button value="active">{{ t('subscription.active') }}</a-radio-button>
<a-radio-button value="deactive">{{ t('disabled') }}</a-radio-button>
<a-radio-button value="depleted">{{ t('depleted') }}</a-radio-button>
<a-radio-button value="expiring">{{ t('depletingSoon') }}</a-radio-button>
<a-radio-button value="online">{{ t('online') }}</a-radio-button>
</a-radio-group>
<a-select v-model:value="protocolFilter" allow-clear :placeholder="t('pages.inbounds.protocol')"
:size="isMobile ? 'small' : 'middle'" :style="{ width: '150px' }">
<a-select-option v-for="protocol in protocolOptions" :key="protocol" :value="protocol">
{{ protocol }}
</a-select-option>
</a-select>
</div>
<a-table v-if="!isMobile" :columns="columns" :data-source="filteredClients" :loading="loading" row-key="id"
:row-selection="rowSelection"
:pagination="{ pageSize: 20, showSizeChanger: true, pageSizeOptions: ['10', '20', '50', '100'] }"
size="small">
@ -456,7 +560,7 @@ const columns = computed(() => [
<a-spin v-else :spinning="loading">
<div class="client-cards">
<div v-if="clients.length > 0" class="card-bulk-bar">
<div v-if="filteredClients.length > 0" class="card-bulk-bar">
<a-checkbox :checked="allSelected" :indeterminate="someSelected"
@change="(e) => selectAll(e.target.checked)">
{{ t('pages.clients.selectAll') || 'Select all' }}
@ -466,12 +570,12 @@ const columns = computed(() => [
</span>
</div>
<div v-if="clients.length === 0" class="card-empty">
<div v-if="filteredClients.length === 0" class="card-empty">
<UserOutlined style="font-size: 28px; opacity: 0.5" />
<div>{{ t('pages.clients.empty') || 'No clients yet.' }}</div>
</div>
<div v-for="row in clients" :key="row.id" class="client-card"
<div v-for="row in filteredClients" :key="row.id" class="client-card"
:class="{ 'is-selected': isSelected(row.id) }">
<div class="card-head">
<a-checkbox :checked="isSelected(row.id)"
@ -554,6 +658,23 @@ const columns = computed(() => [
background: transparent;
}
.filter-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.filter-bar.mobile {
gap: 6px;
margin-bottom: 8px;
}
.filter-bar.mobile > * {
flex: 0 0 auto;
}
.content-area {
padding: 24px;
}

View file

@ -12,6 +12,8 @@ export function useClients() {
const fetched = ref(false);
const subSettings = ref({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false });
const ipLimitEnable = ref(false);
const expireDiff = ref(0);
const trafficDiff = ref(0);
let onlinesTimer = null;
async function refresh() {
@ -44,6 +46,8 @@ export function useClients() {
subJsonEnable: !!s.subJsonEnable,
};
ipLimitEnable.value = !!s.ipLimitEnable;
expireDiff.value = (s.expireDiff ?? 0) * 86400000;
trafficDiff.value = (s.trafficDiff ?? 0) * 1073741824;
}
async function refreshOnlines() {
@ -141,6 +145,8 @@ export function useClients() {
fetched,
subSettings,
ipLimitEnable,
expireDiff,
trafficDiff,
refresh,
refreshOnlines,
create,

View file

@ -1,11 +1,9 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import {
PlusOutlined,
MenuOutlined,
SearchOutlined,
FilterOutlined,
MoreOutlined,
EditOutlined,
QrcodeOutlined,
@ -19,9 +17,8 @@ import {
InfoCircleOutlined,
} from '@ant-design/icons-vue';
import { HttpUtil, ObjectUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
import { HttpUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
import { DBInbound } from '@/models/dbinbound.js';
import { Inbound } from '@/models/inbound.js';
import InfinityIcon from '@/components/InfinityIcon.vue';
import { useDatepicker } from '@/composables/useDatepicker.js';
@ -54,107 +51,6 @@ const emit = defineEmits([
'row-action',
]);
// ============ Toolbar / search & filter =============================
const FILTER_STATE_KEY = 'inboundsFilterState';
const savedFilterState = (() => {
try {
return JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}');
} catch (_e) {
return {};
}
})();
const enableFilter = ref(!!savedFilterState.enableFilter);
const searchKey = ref(savedFilterState.searchKey || '');
const filterBy = ref(savedFilterState.filterBy || '');
const protocolFilter = ref(savedFilterState.protocolFilter || undefined);
const nodeFilter = ref(savedFilterState.nodeFilter || '');
watch([enableFilter, searchKey, filterBy, protocolFilter, nodeFilter], () => {
localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({
enableFilter: enableFilter.value,
searchKey: searchKey.value,
filterBy: filterBy.value,
protocolFilter: protocolFilter.value,
nodeFilter: nodeFilter.value,
}));
});
// Toggle the filter mode flip cleans the other input.
function onToggleFilter() {
if (enableFilter.value) searchKey.value = '';
else filterBy.value = '';
}
const protocolOptions = computed(() => {
const values = new Set(props.dbInbounds.map((i) => i.protocol).filter(Boolean));
return [...values].sort();
});
const nodeOptions = computed(() => {
const values = new Map();
if (props.dbInbounds.some((i) => i.nodeId == null)) {
values.set('local', t('pages.inbounds.localPanel'));
}
for (const dbInbound of props.dbInbounds) {
if (dbInbound.nodeId == null) continue;
const node = props.nodesById.get(dbInbound.nodeId);
values.set(String(dbInbound.nodeId), node?.name || `#${dbInbound.nodeId}`);
}
return [...values.entries()].map(([value, label]) => ({ value, label }));
});
function applySecondaryFilters(rows) {
return rows.filter((dbInbound) => {
if (protocolFilter.value && dbInbound.protocol !== protocolFilter.value) return false;
if (nodeFilter.value) {
const nodeValue = dbInbound.nodeId == null ? 'local' : String(dbInbound.nodeId);
if (nodeValue !== nodeFilter.value) return false;
}
return true;
});
}
// ============ Search / filter projection =============================
// Mirrors the legacy logic: when searching, keep inbounds that match
// anywhere (deep search); when filtering, keep inbounds that have at
// least one client in the requested bucket and reduce their settings
// to that bucket.
function projectInbound(dbInbound, predicate) {
const next = new DBInbound(dbInbound);
let settings;
try {
settings = JSON.parse(dbInbound.settings || '{}');
} catch (_e) {
settings = {};
}
if (!Array.isArray(settings.clients)) return next;
const filtered = settings.clients.filter(predicate);
next.settings = Inbound.Settings.fromJson(dbInbound.protocol, { clients: filtered });
next.invalidateCache();
return next;
}
const visibleInbounds = computed(() => {
if (enableFilter.value) {
if (ObjectUtil.isEmpty(filterBy.value)) return applySecondaryFilters([...props.dbInbounds]);
const out = [];
for (const dbInbound of props.dbInbounds) {
const c = props.clientCount[dbInbound.id];
if (!c || !c[filterBy.value] || c[filterBy.value].length === 0) continue;
const list = c[filterBy.value];
out.push(projectInbound(dbInbound, (client) => list.includes(client.email)));
}
return applySecondaryFilters(out);
}
if (ObjectUtil.isEmpty(searchKey.value)) return applySecondaryFilters([...props.dbInbounds]);
const out = [];
for (const dbInbound of props.dbInbounds) {
if (!ObjectUtil.deepSearch(dbInbound, searchKey.value)) continue;
out.push(projectInbound(dbInbound, (client) => ObjectUtil.deepSearch(client, searchKey.value)));
}
return applySecondaryFilters(out);
});
// ============ Sorting =================================================
const sortState = ref({ column: null, order: null });
@ -186,10 +82,10 @@ const sortFns = {
const sortedInbounds = computed(() => {
const { column, order } = sortState.value;
if (!column || !order) return visibleInbounds.value;
if (!column || !order) return props.dbInbounds;
const fn = sortFns[column];
if (!fn) return visibleInbounds.value;
const sorted = [...visibleInbounds.value].sort(fn);
if (!fn) return props.dbInbounds;
const sorted = [...props.dbInbounds].sort(fn);
return order === 'descend' ? sorted.reverse() : sorted;
});
@ -200,10 +96,6 @@ function onTableChange(_pag, _filters, sorter) {
};
}
watch([searchKey, filterBy], () => {
sortState.value = { column: null, order: null };
});
// ============ Columns =================================================
// `key`-driven so we can render via the body-cell slot below. AD-Vue 4's
// `responsive` array still works on column defs. Computed so column
@ -322,41 +214,6 @@ function showQrCodeMenu(dbInbound) {
</template>
<a-space direction="vertical" :style="{ width: '100%' }">
<!-- Search / filter toolbar -->
<div :class="isMobile ? 'filter-bar mobile' : 'filter-bar'">
<a-switch v-model:checked="enableFilter" @change="onToggleFilter">
<template #checkedChildren>
<SearchOutlined />
</template>
<template #unCheckedChildren>
<FilterOutlined />
</template>
</a-switch>
<a-input v-if="!enableFilter" v-model:value="searchKey" :placeholder="t('search')" autofocus
:size="isMobile ? 'small' : 'middle'" :style="{ maxWidth: '300px' }" />
<a-radio-group v-if="enableFilter" v-model:value="filterBy" button-style="solid"
:size="isMobile ? 'small' : 'middle'">
<a-radio-button value="">{{ t('none') }}</a-radio-button>
<a-radio-button value="active">{{ t('subscription.active') }}</a-radio-button>
<a-radio-button value="deactive">{{ t('disabled') }}</a-radio-button>
<a-radio-button value="depleted">{{ t('depleted') }}</a-radio-button>
<a-radio-button value="expiring">{{ t('depletingSoon') }}</a-radio-button>
<a-radio-button value="online">{{ t('online') }}</a-radio-button>
</a-radio-group>
<a-select v-model:value="protocolFilter" allow-clear :placeholder="t('pages.inbounds.protocol')"
:size="isMobile ? 'small' : 'middle'" :style="{ width: '150px' }">
<a-select-option v-for="protocol in protocolOptions" :key="protocol" :value="protocol">
{{ protocol }}
</a-select-option>
</a-select>
<a-select v-if="hasActiveNode && nodeOptions.length > 0" v-model:value="nodeFilter" allow-clear
:placeholder="t('pages.inbounds.node')" :size="isMobile ? 'small' : 'middle'" :style="{ width: '170px' }">
<a-select-option v-for="node in nodeOptions" :key="node.value" :value="node.value">
{{ node.label }}
</a-select-option>
</a-select>
</div>
<!-- ====================== Mobile: card list ======================= -->
<div v-if="isMobile" class="inbound-cards">
<div v-if="visibleInbounds.length === 0" class="card-empty"></div>
@ -658,20 +515,6 @@ function showQrCodeMenu(dbInbound) {
</template>
<style scoped>
.filter-bar {
display: flex;
align-items: center;
gap: 8px;
}
.filter-bar.mobile {
display: block;
}
.filter-bar.mobile>* {
margin-bottom: 4px;
}
.action-buttons {
display: flex;
align-items: center;
@ -830,16 +673,6 @@ function showQrCodeMenu(dbInbound) {
padding: 8px;
}
.filter-bar.mobile {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.filter-bar.mobile>* {
margin-bottom: 0;
}
.row-action-trigger {
font-size: 22px;
padding: 4px;

View file

@ -319,6 +319,14 @@ function confirmClone(dbInbound) {
cancelText: 'Cancel',
onOk: async () => {
const baseInbound = dbInbound.toInbound();
let clonedSettings = '';
try {
const raw = JSON.parse(dbInbound.settings || '{}');
raw.clients = [];
clonedSettings = JSON.stringify(raw, null, 2);
} catch (_e) {
clonedSettings = Inbound.Settings.getSettings(baseInbound.protocol).toString();
}
const data = {
up: 0,
down: 0,
@ -329,7 +337,7 @@ function confirmClone(dbInbound) {
listen: '',
port: RandomUtil.randomInteger(10000, 60000),
protocol: baseInbound.protocol,
settings: Inbound.Settings.getSettings(baseInbound.protocol).toString(),
settings: clonedSettings,
streamSettings: baseInbound.stream.toString(),
sniffing: baseInbound.sniffing.toString(),
};

View file

@ -394,6 +394,7 @@
"method": "Method",
"first": "First",
"last": "Last",
"ipLog": "IP Log",
"prefix": "Prefix",
"postfix": "Postfix",
"delayedStart": "Start After First Use",