mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 20:54:14 +00:00
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:
parent
418acf8cfa
commit
e9fce827ac
6 changed files with 153 additions and 184 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -394,6 +394,7 @@
|
|||
"method": "Method",
|
||||
"first": "First",
|
||||
"last": "Last",
|
||||
"ipLog": "IP Log",
|
||||
"prefix": "Prefix",
|
||||
"postfix": "Postfix",
|
||||
"delayedStart": "Start After First Use",
|
||||
|
|
@ -1035,4 +1036,4 @@
|
|||
"chooseInbound": "Choose an Inbound"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue