mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 12:44:22 +00:00
refactor(inbounds): remove legacy per-inbound client UI
Now that clients live as first-class rows attached to one or many inbounds, the per-inbound client UI on the inbounds page is dead weight — every client action either has a global equivalent on the Clients page or makes no sense in a many-to-many world. Deletes ClientFormModal, ClientBulkModal, CopyClientsModal, and ClientRowTable from inbounds/. Strips the matching emits, refs, handlers, and dropdown menu items from InboundList and InboundsPage, and removes the dead mobile expand-chevron state and the desktop expanded-row plumbing that drove the inline client table. The InboundFormModal Clients tab still works in add-mode (one inline client at inbound creation) — that flow goes through ClientService. SyncInbound on save and remains useful. Fixes a stray "</a-dropdown>" left over by an earlier toolbar edit in ClientsPage that broke the template parser. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
93ede81094
commit
c5217b9a78
7 changed files with 14 additions and 1921 deletions
|
|
@ -10,8 +10,6 @@ import {
|
|||
InfoCircleOutlined,
|
||||
QrcodeOutlined,
|
||||
RetweetOutlined,
|
||||
ControlOutlined,
|
||||
DownOutlined,
|
||||
MoreOutlined,
|
||||
UsergroupAddOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
|
@ -218,15 +216,14 @@ function onShowQr(row) {
|
|||
|
||||
function onResetAllTraffics() {
|
||||
Modal.confirm({
|
||||
title: t('pages.clients.resetAllTrafficsTitle') || 'Reset all client traffic?',
|
||||
content: t('pages.clients.resetAllTrafficsContent')
|
||||
|| 'Every client’s up/down counter drops to zero. Quotas and expiry are not affected.',
|
||||
title: t('pages.clients.resetAllTrafficsTitle'),
|
||||
content: t('pages.clients.resetAllTrafficsContent'),
|
||||
okText: t('reset') || 'Reset',
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
const msg = await resetAllTraffics();
|
||||
if (msg?.success) message.success(t('pages.clients.toasts.allTrafficsReset') || 'All client traffic reset');
|
||||
if (msg?.success) message.success(t('pages.clients.toasts.allTrafficsReset'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -340,23 +337,12 @@ const columns = computed(() => [
|
|||
{{ t('pages.clients.deleteSelected', { count: selectedRowKeys.length })
|
||||
|| `Delete (${selectedRowKeys.length})` }}
|
||||
</a-button>
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-button size="small">
|
||||
<ControlOutlined />
|
||||
<span v-if="!isMobile">{{ t('pages.clients.general') }}</span>
|
||||
<DownOutlined />
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item key="resetAllTraffics" @click="onResetAllTraffics">
|
||||
<RetweetOutlined />
|
||||
<span style="margin-left: 6px">
|
||||
{{ t('pages.clients.resetAllTraffics') }}
|
||||
</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
<a-button size="small" @click="onResetAllTraffics">
|
||||
<template #icon>
|
||||
<RetweetOutlined />
|
||||
</template>
|
||||
</a-dropdown>
|
||||
<template v-if="!isMobile">{{ t('pages.clients.resetAllTraffics') }}</template>
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -462,7 +448,8 @@ const columns = computed(() => [
|
|||
<div class="card-head">
|
||||
<a-checkbox :checked="isSelected(row.id)"
|
||||
@change="(e) => toggleSelect(row.id, e.target.checked)" />
|
||||
<a-badge :color="row.enable && isOnline(row.email) ? 'green' : (row.enable ? 'default' : 'red')" />
|
||||
<a-badge
|
||||
:color="row.enable && isOnline(row.email) ? 'green' : (row.enable ? 'default' : 'red')" />
|
||||
<span class="tag-name">{{ row.email }}</span>
|
||||
<div class="card-actions" @click.stop>
|
||||
<a-tooltip :title="t('pages.clients.moreInformation') || 'Info'">
|
||||
|
|
|
|||
|
|
@ -1,280 +0,0 @@
|
|||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import dayjs from 'dayjs';
|
||||
import { SyncOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
import { HttpUtil, RandomUtil, SizeFormatter } from '@/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
import {
|
||||
Inbound,
|
||||
Protocols,
|
||||
USERS_SECURITY,
|
||||
TLS_FLOW_CONTROL,
|
||||
} from '@/models/inbound.js';
|
||||
import DateTimePicker from '@/components/DateTimePicker.vue';
|
||||
|
||||
// Bulk-add up to 500 clients in one go. The legacy panel offers five
|
||||
// generation modes — this component preserves them all:
|
||||
// 0: Random — N fully-random emails (no prefix)
|
||||
// 1: Random+Prefix — N random emails preceded by `prefix`
|
||||
// 2: Random+Prefix+Num — emails like `<rand><prefix><num>` for num in [first..last]
|
||||
// 3: Random+Prefix+Num+Postfix — same + appended postfix
|
||||
// 4: Prefix+Num+Postfix — no random part, just `<prefix><num><postfix>`
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
dbInbound: { type: Object, default: null },
|
||||
subEnable: { type: Boolean, default: false },
|
||||
tgBotEnable: { type: Boolean, default: false },
|
||||
ipLimitEnable: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open', 'saved']);
|
||||
|
||||
const SECURITY_OPTIONS = Object.values(USERS_SECURITY);
|
||||
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||
|
||||
// === Reactive form state ===========================================
|
||||
// Cloned inbound (so canEnableTlsFlow() works).
|
||||
const inbound = ref(null);
|
||||
const saving = ref(false);
|
||||
const delayedStart = ref(false);
|
||||
|
||||
const form = reactive({
|
||||
emailMethod: 0,
|
||||
firstNum: 1,
|
||||
lastNum: 1,
|
||||
emailPrefix: '',
|
||||
emailPostfix: '',
|
||||
quantity: 1,
|
||||
security: USERS_SECURITY.AUTO,
|
||||
flow: '',
|
||||
subId: '',
|
||||
tgId: 0,
|
||||
comment: '',
|
||||
limitIp: 0,
|
||||
totalGB: 0,
|
||||
expiryTime: 0, // ms epoch; negative => delayed start days
|
||||
reset: 0,
|
||||
});
|
||||
|
||||
const expiryDate = computed({
|
||||
get: () => (form.expiryTime > 0 ? dayjs(form.expiryTime) : null),
|
||||
set: (next) => { form.expiryTime = next ? next.valueOf() : 0; },
|
||||
});
|
||||
|
||||
const delayedExpireDays = computed({
|
||||
get: () => (form.expiryTime < 0 ? form.expiryTime / -86400000 : 0),
|
||||
set: (days) => { form.expiryTime = -86400000 * (days || 0); },
|
||||
});
|
||||
|
||||
watch(() => props.open, (next) => {
|
||||
if (!next) return;
|
||||
if (!props.dbInbound) return;
|
||||
inbound.value = Inbound.fromJson(props.dbInbound.toInbound().toJson());
|
||||
// Reset all form fields on every open — bulk add is intentionally
|
||||
// stateless between sessions (legacy resets on .show()).
|
||||
form.emailMethod = 0;
|
||||
form.firstNum = 1;
|
||||
form.lastNum = 1;
|
||||
form.emailPrefix = '';
|
||||
form.emailPostfix = '';
|
||||
form.quantity = 1;
|
||||
form.security = USERS_SECURITY.AUTO;
|
||||
form.flow = '';
|
||||
form.subId = '';
|
||||
form.tgId = 0;
|
||||
form.comment = '';
|
||||
form.limitIp = 0;
|
||||
form.totalGB = 0;
|
||||
form.expiryTime = 0;
|
||||
form.reset = 0;
|
||||
delayedStart.value = false;
|
||||
});
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
function makeNewClient(parsed) {
|
||||
switch (parsed.protocol) {
|
||||
case Protocols.VMESS: return new Inbound.VmessSettings.VMESS();
|
||||
case Protocols.VLESS: return new Inbound.VLESSSettings.VLESS();
|
||||
case Protocols.TROJAN: return new Inbound.TrojanSettings.Trojan();
|
||||
case Protocols.SHADOWSOCKS: {
|
||||
const method = parsed.settings.shadowsockses[0]?.method || parsed.settings.method;
|
||||
return new Inbound.ShadowsocksSettings.Shadowsocks(method);
|
||||
}
|
||||
case Protocols.HYSTERIA: return new Inbound.HysteriaSettings.Hysteria();
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildClients() {
|
||||
if (!inbound.value) return [];
|
||||
const out = [];
|
||||
const method = form.emailMethod;
|
||||
let start;
|
||||
let end;
|
||||
if (method > 1) {
|
||||
start = form.firstNum;
|
||||
end = form.lastNum + 1;
|
||||
} else {
|
||||
start = 0;
|
||||
end = form.quantity;
|
||||
}
|
||||
const prefix = method > 0 && form.emailPrefix.length > 0 ? form.emailPrefix : '';
|
||||
const useNum = method > 1;
|
||||
const postfix = method > 2 && form.emailPostfix.length > 0 ? form.emailPostfix : '';
|
||||
|
||||
for (let i = start; i < end; i++) {
|
||||
const c = makeNewClient(inbound.value);
|
||||
if (!c) continue;
|
||||
if (method === 4) c.email = '';
|
||||
c.email += useNum ? prefix + String(i) + postfix : prefix + postfix;
|
||||
|
||||
if (form.subId.length > 0) c.subId = form.subId;
|
||||
c.tgId = form.tgId;
|
||||
if (form.comment.length > 0) c.comment = form.comment;
|
||||
c.security = form.security;
|
||||
c.limitIp = form.limitIp;
|
||||
// Use the clien's totalGB setter (ms epoch and bytes already handled
|
||||
// identically for bulk and single client paths).
|
||||
c.totalGB = Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB);
|
||||
c.expiryTime = form.expiryTime;
|
||||
if (inbound.value.canEnableTlsFlow()) c.flow = form.flow;
|
||||
c.reset = form.reset;
|
||||
out.push(c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const clients = buildClients();
|
||||
if (clients.length === 0) return;
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
id: props.dbInbound.id,
|
||||
// Clients all serialize via toString() — same shape the single-
|
||||
// client modal posts. Joining with `,` lets the Go side parse the
|
||||
// outer array directly.
|
||||
settings: `{"clients": [${clients.map((c) => c.toString()).join(',')}]}`,
|
||||
};
|
||||
const msg = await HttpUtil.post('/panel/api/inbounds/addClient', payload);
|
||||
if (msg?.success) {
|
||||
emit('saved');
|
||||
close();
|
||||
}
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="t('pages.clients.bulk')" :ok-text="t('create')" :cancel-text="t('close')"
|
||||
:confirm-loading="saving" :mask-closable="false" @ok="submit" @cancel="close">
|
||||
<a-form v-if="inbound" :colon="false" :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }">
|
||||
<a-form-item :label="t('pages.clients.method')">
|
||||
<a-select v-model:value="form.emailMethod">
|
||||
<a-select-option :value="0">Random</a-select-option>
|
||||
<a-select-option :value="1">Random + Prefix</a-select-option>
|
||||
<a-select-option :value="2">Random + Prefix + Num</a-select-option>
|
||||
<a-select-option :value="3">Random + Prefix + Num + Postfix</a-select-option>
|
||||
<a-select-option :value="4">Prefix + Num + Postfix</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="form.emailMethod > 1" :label="t('pages.clients.first')">
|
||||
<a-input-number v-model:value="form.firstNum" :min="1" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="form.emailMethod > 1" :label="t('pages.clients.last')">
|
||||
<a-input-number v-model:value="form.lastNum" :min="form.firstNum" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="form.emailMethod > 0" :label="t('pages.clients.prefix')">
|
||||
<a-input v-model:value="form.emailPrefix" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="form.emailMethod > 2" :label="t('pages.clients.postfix')">
|
||||
<a-input v-model:value="form.emailPostfix" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="form.emailMethod < 2" :label="t('pages.clients.clientCount')">
|
||||
<a-input-number v-model:value="form.quantity" :min="1" :max="500" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="inbound.protocol === Protocols.VMESS" :label="t('security')">
|
||||
<a-select v-model:value="form.security">
|
||||
<a-select-option v-for="key in SECURITY_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="inbound.canEnableTlsFlow()" label="Flow">
|
||||
<a-select v-model:value="form.flow">
|
||||
<a-select-option value="">{{ t('none') }}</a-select-option>
|
||||
<a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="subEnable">
|
||||
<template #label>
|
||||
{{ t('subscription.title') }}
|
||||
<SyncOutlined class="random-icon" @click="form.subId = RandomUtil.randomLowerAndNum(16)" />
|
||||
</template>
|
||||
<a-input v-model:value="form.subId" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="tgBotEnable" label="Telegram ID">
|
||||
<a-input-number v-model:value="form.tgId" :min="0" :style="{ width: '50%' }" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item :label="t('comment')">
|
||||
<a-input v-model:value="form.comment" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="ipLimitEnable" :label="t('pages.inbounds.IPLimit')">
|
||||
<a-input-number v-model:value="form.limitIp" :min="0" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<template #label>
|
||||
<a-tooltip :title="t('pages.inbounds.meansNoLimit')">{{ t('pages.inbounds.totalFlow') }}</a-tooltip>
|
||||
</template>
|
||||
<a-input-number v-model:value="form.totalGB" :min="0" :step="0.1" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item :label="t('pages.clients.delayedStart')">
|
||||
<a-switch v-model:checked="delayedStart" @click="form.expiryTime = 0" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="delayedStart" :label="t('pages.clients.expireDays')">
|
||||
<a-input-number v-model:value="delayedExpireDays" :min="0" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-else>
|
||||
<template #label>
|
||||
<a-tooltip :title="t('pages.inbounds.leaveBlankToNeverExpire')">{{ t('pages.inbounds.expireDate')
|
||||
}}</a-tooltip>
|
||||
</template>
|
||||
<DateTimePicker v-model:value="expiryDate" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="form.expiryTime !== 0">
|
||||
<template #label>
|
||||
<a-tooltip :title="t('pages.clients.renewDesc')">{{ t('pages.clients.renew') }}</a-tooltip>
|
||||
</template>
|
||||
<a-input-number v-model:value="form.reset" :min="0" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.random-icon {
|
||||
margin-left: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--ant-primary-color, #1890ff);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,394 +0,0 @@
|
|||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import dayjs from 'dayjs';
|
||||
import { SyncOutlined, RetweetOutlined, DeleteOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
import {
|
||||
HttpUtil,
|
||||
RandomUtil,
|
||||
SizeFormatter,
|
||||
ColorUtils,
|
||||
} from '@/utils';
|
||||
import { Inbound, Protocols, USERS_SECURITY, TLS_FLOW_CONTROL } from '@/models/inbound.js';
|
||||
import DateTimePicker from '@/components/DateTimePicker.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Add OR edit a single client on a multi-user inbound (VMess / VLess /
|
||||
// Trojan / Shadowsocks-multi / Hysteria). The legacy panel routes both
|
||||
// flows through the same modal — same here.
|
||||
//
|
||||
// On submit we serialize the client via its toString() (which is just
|
||||
// JSON.stringify of toJson()) and post it inside a one-element clients
|
||||
// array so the Go side reuses the same parsing path as the inbound
|
||||
// settings update.
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
mode: { type: String, default: 'add', validator: (v) => ['add', 'edit'].includes(v) },
|
||||
dbInbound: { type: Object, default: null },
|
||||
clientIndex: { type: Number, default: null },
|
||||
// Sidecar config from the inbounds page — controls visibility of
|
||||
// the Subscription, Telegram, and IP-limit fields.
|
||||
subEnable: { type: Boolean, default: false },
|
||||
tgBotEnable: { type: Boolean, default: false },
|
||||
ipLimitEnable: { type: Boolean, default: false },
|
||||
trafficDiff: { type: Number, default: 0 },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open', 'saved']);
|
||||
|
||||
// === Reactive draft =================================================
|
||||
const inbound = ref(null);
|
||||
const client = ref(null);
|
||||
const oldClientId = ref('');
|
||||
const clientStats = ref(null);
|
||||
|
||||
const saving = ref(false);
|
||||
const delayedStart = ref(false);
|
||||
|
||||
const SECURITY_OPTIONS = Object.values(USERS_SECURITY);
|
||||
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||
|
||||
const protocol = computed(() => inbound.value?.protocol);
|
||||
const isVmessOrVless = computed(() =>
|
||||
protocol.value === Protocols.VMESS || protocol.value === Protocols.VLESS,
|
||||
);
|
||||
const isTrojanOrSS = computed(() =>
|
||||
protocol.value === Protocols.TROJAN || protocol.value === Protocols.SHADOWSOCKS,
|
||||
);
|
||||
|
||||
const expiryDate = computed({
|
||||
get: () => (client.value?.expiryTime > 0 ? dayjs(client.value.expiryTime) : null),
|
||||
set: (next) => { if (client.value) client.value.expiryTime = next ? next.valueOf() : 0; },
|
||||
});
|
||||
|
||||
const delayedExpireDays = computed({
|
||||
get: () => {
|
||||
if (!client.value || client.value.expiryTime >= 0) return 0;
|
||||
return client.value.expiryTime / -86400000;
|
||||
},
|
||||
set: (days) => {
|
||||
if (!client.value) return;
|
||||
client.value.expiryTime = -86400000 * (days || 0);
|
||||
},
|
||||
});
|
||||
|
||||
const totalGB = computed({
|
||||
get: () => {
|
||||
if (!client.value || !client.value.totalGB) return 0;
|
||||
return Math.round((client.value.totalGB / SizeFormatter.ONE_GB) * 100) / 100;
|
||||
},
|
||||
set: (gb) => {
|
||||
if (!client.value) return;
|
||||
client.value.totalGB = Math.round((gb || 0) * SizeFormatter.ONE_GB);
|
||||
},
|
||||
});
|
||||
|
||||
const isExpired = computed(() => {
|
||||
if (props.mode !== 'edit' || !client.value) return false;
|
||||
return client.value.expiryTime > 0 && client.value.expiryTime < Date.now();
|
||||
});
|
||||
const isTrafficExhausted = computed(() => {
|
||||
if (!clientStats.value || clientStats.value.total <= 0) return false;
|
||||
return clientStats.value.up + clientStats.value.down >= clientStats.value.total;
|
||||
});
|
||||
|
||||
function getClientId(proto, c) {
|
||||
switch (proto) {
|
||||
case Protocols.TROJAN: return c.password;
|
||||
case Protocols.SHADOWSOCKS: return c.email;
|
||||
case Protocols.HYSTERIA: return c.auth;
|
||||
default: return c.id;
|
||||
}
|
||||
}
|
||||
|
||||
function makeNewClient(proto, parsed) {
|
||||
switch (proto) {
|
||||
case Protocols.VMESS: return new Inbound.VmessSettings.VMESS();
|
||||
case Protocols.VLESS: return new Inbound.VLESSSettings.VLESS();
|
||||
case Protocols.TROJAN: return new Inbound.TrojanSettings.Trojan();
|
||||
case Protocols.SHADOWSOCKS: {
|
||||
const method = parsed.settings.method;
|
||||
return new Inbound.ShadowsocksSettings.Shadowsocks(
|
||||
method,
|
||||
RandomUtil.randomShadowsocksPassword(method),
|
||||
);
|
||||
}
|
||||
case Protocols.HYSTERIA: return new Inbound.HysteriaSettings.Hysteria();
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.open, (next) => {
|
||||
if (!next) return;
|
||||
if (!props.dbInbound) return;
|
||||
const parsed = Inbound.fromJson(props.dbInbound.toInbound().toJson());
|
||||
inbound.value = parsed;
|
||||
delayedStart.value = false;
|
||||
|
||||
if (props.mode === 'edit') {
|
||||
const idx = props.clientIndex ?? 0;
|
||||
client.value = parsed.clients[idx];
|
||||
if (client.value && client.value.expiryTime < 0) delayedStart.value = true;
|
||||
oldClientId.value = getClientId(parsed.protocol, client.value);
|
||||
} else {
|
||||
const c = makeNewClient(parsed.protocol, parsed);
|
||||
if (c) parsed.clients.push(c);
|
||||
client.value = parsed.clients[parsed.clients.length - 1];
|
||||
oldClientId.value = '';
|
||||
}
|
||||
|
||||
clientStats.value = (props.dbInbound.clientStats || []).find(
|
||||
(s) => s.email === client.value?.email,
|
||||
) || null;
|
||||
});
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
function randomEmail() {
|
||||
if (client.value) client.value.email = RandomUtil.randomLowerAndNum(9);
|
||||
}
|
||||
function randomId() {
|
||||
if (client.value) client.value.id = RandomUtil.randomUUID();
|
||||
}
|
||||
function randomPassword() {
|
||||
if (!client.value || !inbound.value) return;
|
||||
if (inbound.value.protocol === Protocols.SHADOWSOCKS) {
|
||||
client.value.password = RandomUtil.randomShadowsocksPassword(
|
||||
inbound.value.settings.method,
|
||||
);
|
||||
} else {
|
||||
client.value.password = RandomUtil.randomSeq(10);
|
||||
}
|
||||
}
|
||||
function randomAuth() {
|
||||
if (client.value) client.value.auth = RandomUtil.randomSeq(10);
|
||||
}
|
||||
function randomSubId() {
|
||||
if (client.value) client.value.subId = RandomUtil.randomLowerAndNum(16);
|
||||
}
|
||||
|
||||
const clientIpsText = ref('');
|
||||
async function loadClientIps() {
|
||||
if (!client.value?.email) return;
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/clientIps/${client.value.email}`);
|
||||
if (!msg?.success) {
|
||||
clientIpsText.value = msg?.obj || '';
|
||||
return;
|
||||
}
|
||||
let ips = msg.obj;
|
||||
if (typeof ips === 'string' && ips.startsWith('[') && ips.endsWith(']')) {
|
||||
try {
|
||||
const parsed = JSON.parse(ips);
|
||||
ips = Array.isArray(parsed) ? parsed.join('\n') : ips;
|
||||
} catch (_e) {
|
||||
// leave as raw
|
||||
}
|
||||
}
|
||||
clientIpsText.value = ips || '';
|
||||
}
|
||||
async function clearClientIps() {
|
||||
if (!client.value?.email) return;
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/clearClientIps/${client.value.email}`);
|
||||
if (msg?.success) clientIpsText.value = '';
|
||||
}
|
||||
|
||||
async function resetClientTraffic() {
|
||||
if (!clientStats.value || !client.value?.email) return;
|
||||
const msg = await HttpUtil.post(
|
||||
`/panel/api/inbounds/${props.dbInbound.id}/resetClientTraffic/${client.value.email}`,
|
||||
);
|
||||
if (msg?.success) {
|
||||
clientStats.value.up = 0;
|
||||
clientStats.value.down = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!client.value || !inbound.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
id: props.dbInbound.id,
|
||||
settings: `{"clients": [${client.value.toString()}]}`,
|
||||
};
|
||||
const url = props.mode === 'edit'
|
||||
? `/panel/api/inbounds/updateClient/${oldClientId.value}`
|
||||
: '/panel/api/inbounds/addClient';
|
||||
const msg = await HttpUtil.post(url, payload);
|
||||
if (msg?.success) {
|
||||
emit('saved');
|
||||
close();
|
||||
}
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const title = computed(() =>
|
||||
props.mode === 'edit' ? t('pages.clients.edit') : t('pages.clients.add'),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="title"
|
||||
:ok-text="mode === 'edit' ? t('pages.clients.submitEdit') : t('pages.clients.submitAdd')" :cancel-text="t('close')"
|
||||
:confirm-loading="saving" :mask-closable="false" @ok="submit" @cancel="close">
|
||||
<a-tag v-if="mode === 'edit' && (isExpired || isTrafficExhausted)" color="red" class="status-banner">
|
||||
{{ t('depleted') }}
|
||||
</a-tag>
|
||||
|
||||
<a-form v-if="client && inbound" layout="horizontal" :colon="false" :label-col="{ sm: { span: 8 } }"
|
||||
:wrapper-col="{ sm: { span: 14 } }">
|
||||
<a-form-item :label="t('enable')">
|
||||
<a-switch v-model:checked="client.enable" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<template #label>
|
||||
{{ t('pages.inbounds.email') }}
|
||||
<SyncOutlined class="random-icon" @click="randomEmail" />
|
||||
</template>
|
||||
<a-input v-model:value="client.email" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="isTrojanOrSS">
|
||||
<template #label>
|
||||
{{ t('password') }}
|
||||
<SyncOutlined class="random-icon" @click="randomPassword" />
|
||||
</template>
|
||||
<a-input v-model:value="client.password" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="protocol === Protocols.HYSTERIA">
|
||||
<template #label>
|
||||
{{ t('password') }}
|
||||
<SyncOutlined class="random-icon" @click="randomAuth" />
|
||||
</template>
|
||||
<a-input v-model:value="client.auth" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="isVmessOrVless">
|
||||
<template #label>
|
||||
ID
|
||||
<SyncOutlined class="random-icon" @click="randomId" />
|
||||
</template>
|
||||
<a-input v-model:value="client.id" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="protocol === Protocols.VMESS" :label="t('security')">
|
||||
<a-select v-model:value="client.security">
|
||||
<a-select-option v-for="key in SECURITY_OPTIONS" :key="key" :value="key">
|
||||
{{ key }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="client.email && subEnable">
|
||||
<template #label>
|
||||
{{ t('subscription.title') }}
|
||||
<SyncOutlined class="random-icon" @click="randomSubId" />
|
||||
</template>
|
||||
<a-input v-model:value="client.subId" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="client.email && tgBotEnable" label="Telegram ID">
|
||||
<a-input-number v-model:value="client.tgId" :min="0" :style="{ width: '50%' }" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="client.email" :label="t('comment')">
|
||||
<a-input v-model:value="client.comment" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="ipLimitEnable" :label="t('pages.inbounds.IPLimit')">
|
||||
<a-input-number v-model:value="client.limitIp" :min="0" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="ipLimitEnable && client.limitIp > 0 && client.email && mode === 'edit'"
|
||||
:label="t('pages.inbounds.IPLimitlog')">
|
||||
<a-textarea v-model:value="clientIpsText" readonly :placeholder="t('pages.inbounds.IPLimitlogDesc')"
|
||||
:auto-size="{ minRows: 3, maxRows: 8 }" @click="loadClientIps" />
|
||||
<a-button type="link" size="small" danger @click="clearClientIps">
|
||||
<template #icon>
|
||||
<DeleteOutlined />
|
||||
</template>
|
||||
{{ t('pages.inbounds.IPLimitlogclear') }}
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="inbound.canEnableTlsFlow()" label="Flow">
|
||||
<a-select v-model:value="client.flow">
|
||||
<a-select-option value="">{{ t('none') }}</a-select-option>
|
||||
<a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">
|
||||
{{ key }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="protocol === Protocols.VLESS" label="Reverse tag">
|
||||
<a-input v-model:value="client.reverseTag" placeholder="Optional reverse tag" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<template #label>
|
||||
<a-tooltip :title="t('pages.inbounds.meansNoLimit')">{{ t('pages.inbounds.totalFlow') }}</a-tooltip>
|
||||
</template>
|
||||
<a-input-number v-model:value="totalGB" :min="0" :step="0.1" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="mode === 'edit' && clientStats" :label="t('usage')">
|
||||
<a-tag :color="ColorUtils.clientUsageColor(clientStats, trafficDiff)">
|
||||
{{ SizeFormatter.sizeFormat(clientStats.up) }} /
|
||||
{{ SizeFormatter.sizeFormat(clientStats.down) }}
|
||||
({{ SizeFormatter.sizeFormat(clientStats.up + clientStats.down) }})
|
||||
</a-tag>
|
||||
<a-tooltip v-if="client.email" :title="t('pages.inbounds.resetTraffic')">
|
||||
<RetweetOutlined class="action-icon" @click="resetClientTraffic" />
|
||||
</a-tooltip>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item :label="t('pages.clients.delayedStart')">
|
||||
<a-switch v-model:checked="delayedStart" @click="client.expiryTime = 0" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="delayedStart" :label="t('pages.clients.expireDays')">
|
||||
<a-input-number v-model:value="delayedExpireDays" :min="0" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-else>
|
||||
<template #label>
|
||||
<a-tooltip :title="t('pages.inbounds.leaveBlankToNeverExpire')">{{ t('pages.inbounds.expireDate')
|
||||
}}</a-tooltip>
|
||||
</template>
|
||||
<DateTimePicker v-model:value="expiryDate" />
|
||||
<a-tag v-if="mode === 'edit' && isExpired" color="red">{{ t('depleted') }}</a-tag>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="client.expiryTime !== 0">
|
||||
<template #label>
|
||||
<a-tooltip :title="t('pages.clients.renewDesc')">{{ t('pages.clients.renew') }}</a-tooltip>
|
||||
</template>
|
||||
<a-input-number v-model:value="client.reset" :min="0" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.status-banner {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.random-icon,
|
||||
.action-icon {
|
||||
margin-left: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--ant-primary-color, #1890ff);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,818 +0,0 @@
|
|||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
EditOutlined,
|
||||
InfoCircleOutlined,
|
||||
QrcodeOutlined,
|
||||
RetweetOutlined,
|
||||
DeleteOutlined,
|
||||
EllipsisOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { Modal } from 'ant-design-vue';
|
||||
|
||||
import { SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
|
||||
import InfinityIcon from '@/components/InfinityIcon.vue';
|
||||
import { useDatepicker } from '@/composables/useDatepicker.js';
|
||||
|
||||
const { datepicker } = useDatepicker();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Per-inbound expand-row content. CSS-grid layout (not a nested
|
||||
// <a-table>) so it sits flush inside the parent's expanded cell.
|
||||
// No API calls here — events bubble to the parent's modals.
|
||||
|
||||
const props = defineProps({
|
||||
dbInbound: { type: Object, required: true },
|
||||
isMobile: { type: Boolean, default: false },
|
||||
trafficDiff: { type: Number, default: 0 },
|
||||
expireDiff: { type: Number, default: 0 },
|
||||
onlineClients: { type: Array, default: () => [] },
|
||||
lastOnlineMap: { type: Object, default: () => ({}) },
|
||||
isDarkTheme: { type: Boolean, default: false },
|
||||
pageSize: { type: Number, default: 0 },
|
||||
totalClientCount: { type: Number, default: 0 },
|
||||
statsVersion: { type: Number, default: 0 },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'edit-client',
|
||||
'qrcode-client',
|
||||
'info-client',
|
||||
'reset-traffic-client',
|
||||
'delete-client',
|
||||
'delete-clients',
|
||||
'toggle-enable-client',
|
||||
]);
|
||||
|
||||
const inbound = computed(() => props.dbInbound.toInbound());
|
||||
const clients = computed(() => inbound.value?.clients || []);
|
||||
|
||||
const currentPage = ref(1);
|
||||
const paginatedClients = computed(() => {
|
||||
if (!props.pageSize || props.pageSize <= 0) return clients.value;
|
||||
const start = (currentPage.value - 1) * props.pageSize;
|
||||
return clients.value.slice(start, start + props.pageSize);
|
||||
});
|
||||
|
||||
watch([clients, () => props.pageSize], () => {
|
||||
const total = clients.value.length;
|
||||
const size = props.pageSize > 0 ? props.pageSize : (total || 1);
|
||||
const maxPage = Math.max(1, Math.ceil(total / size));
|
||||
if (currentPage.value > maxPage) currentPage.value = maxPage;
|
||||
});
|
||||
|
||||
// === Per-client stats lookup =======================================
|
||||
// statsVersion bumps on every ws merge so this computed re-evaluates
|
||||
// (DBInbound isn't reactive — the in-place stat mutations alone don't
|
||||
// trigger Vue's tracking).
|
||||
const statsMap = computed(() => {
|
||||
void props.statsVersion;
|
||||
const m = new Map();
|
||||
for (const cs of (props.dbInbound.clientStats || [])) m.set(cs.email, cs);
|
||||
return m;
|
||||
});
|
||||
function statsFor(email) {
|
||||
return email ? statsMap.value.get(email) : null;
|
||||
}
|
||||
|
||||
function getUp(email) { return statsFor(email)?.up || 0; }
|
||||
function getDown(email) { return statsFor(email)?.down || 0; }
|
||||
function getSum(email) { const s = statsFor(email); return s ? s.up + s.down : 0; }
|
||||
function getRem(email) {
|
||||
const s = statsFor(email);
|
||||
if (!s) return 0;
|
||||
const r = s.total - s.up - s.down;
|
||||
return r > 0 ? r : 0;
|
||||
}
|
||||
function isClientDepleted(email) {
|
||||
const s = statsFor(email);
|
||||
if (!s) return false;
|
||||
const total = s.total ?? 0;
|
||||
const used = (s.up ?? 0) + (s.down ?? 0);
|
||||
if (total > 0 && used >= total) return true;
|
||||
const exp = s.expiryTime ?? 0;
|
||||
if (exp > 0 && Date.now() >= exp) return true;
|
||||
return false;
|
||||
}
|
||||
function isClientOnline(email) {
|
||||
return !!email && props.onlineClients.includes(email);
|
||||
}
|
||||
function lastOnlineLabel(email) {
|
||||
const ts = props.lastOnlineMap[email];
|
||||
if (!ts) return '-';
|
||||
return IntlUtil.formatDate(ts, datepicker.value);
|
||||
}
|
||||
|
||||
function statsProgress(email) {
|
||||
const s = statsFor(email);
|
||||
if (!s) return 0;
|
||||
if (s.total === 0) return 100;
|
||||
return (100 * (s.down + s.up)) / s.total;
|
||||
}
|
||||
function expireProgress(expTime, reset) {
|
||||
const now = Date.now();
|
||||
const remainedSec = expTime < 0 ? -expTime / 1000 : (expTime - now) / 1000;
|
||||
const resetSec = reset * 86400;
|
||||
if (remainedSec >= resetSec) return 0;
|
||||
return 100 * (1 - remainedSec / resetSec);
|
||||
}
|
||||
function clientStatsColor(email) {
|
||||
return ColorUtils.clientUsageColor(statsFor(email), props.trafficDiff);
|
||||
}
|
||||
function statsExpColor(email) {
|
||||
// AD-Vue 4 semantic palette mirrors ColorUtils.* so the badge dot
|
||||
// matches the row's traffic/expiry tags.
|
||||
const PURPLE = '#722ed1', SUCCESS = '#52c41a', WARN = '#faad14', DANGER = '#ff4d4f';
|
||||
if (!email) return PURPLE;
|
||||
const s = statsFor(email);
|
||||
if (!s) return PURPLE;
|
||||
const a = ColorUtils.usageColor(s.down + s.up, props.trafficDiff, s.total);
|
||||
const b = ColorUtils.usageColor(Date.now(), props.expireDiff, s.expiryTime);
|
||||
if (a === 'red' || b === 'red') return DANGER;
|
||||
if (a === 'orange' || b === 'orange') return WARN;
|
||||
if (a === 'green' || b === 'green') return SUCCESS;
|
||||
return PURPLE;
|
||||
}
|
||||
|
||||
const isRemovable = computed(() => (props.totalClientCount || clients.value.length) > 1);
|
||||
|
||||
function totalGbDisplay(client) {
|
||||
if (!client.totalGB || client.totalGB <= 0) return '';
|
||||
return `${Math.round((client.totalGB / 1073741824) * 100) / 100} GB`;
|
||||
}
|
||||
|
||||
const isUnlimitedTotal = (client) => !client.totalGB || client.totalGB <= 0;
|
||||
|
||||
function statusBadgeColor(client) {
|
||||
if (!client.enable) return props.isDarkTheme ? '#2c3950' : '#bcbcbc';
|
||||
return statsExpColor(client.email);
|
||||
}
|
||||
|
||||
// === Action confirms ==============================================
|
||||
function confirmReset(client) {
|
||||
Modal.confirm({
|
||||
title: `${t('pages.inbounds.resetTraffic')} — ${client.email}`,
|
||||
content: t('pages.inbounds.resetTrafficContent'),
|
||||
okText: t('reset'),
|
||||
cancelText: t('cancel'),
|
||||
onOk: () => emit('reset-traffic-client', { dbInbound: props.dbInbound, client }),
|
||||
});
|
||||
}
|
||||
function confirmDelete(client) {
|
||||
Modal.confirm({
|
||||
title: `${t('pages.inbounds.deleteClient')} — ${client.email}`,
|
||||
content: t('pages.inbounds.deleteClientContent'),
|
||||
okText: t('delete'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: () => emit('delete-client', { dbInbound: props.dbInbound, client }),
|
||||
});
|
||||
}
|
||||
|
||||
// Stable row key for v-for — falls back through email/id/password
|
||||
// because not every protocol fills the same field.
|
||||
function rowKey(client) {
|
||||
return client.email || client.id || client.password || JSON.stringify(client);
|
||||
}
|
||||
|
||||
const selected = ref(new Set());
|
||||
|
||||
const allSelected = computed(() =>
|
||||
clients.value.length > 0 && clients.value.every((c) => selected.value.has(rowKey(c))),
|
||||
);
|
||||
const someSelected = computed(() =>
|
||||
clients.value.some((c) => selected.value.has(rowKey(c))),
|
||||
);
|
||||
const selectedCount = computed(() => selected.value.size);
|
||||
|
||||
function isSelected(key) {
|
||||
return selected.value.has(key);
|
||||
}
|
||||
function toggleSelect(key, next) {
|
||||
const s = new Set(selected.value);
|
||||
if (next) s.add(key); else s.delete(key);
|
||||
selected.value = s;
|
||||
}
|
||||
function selectAll(next) {
|
||||
if (next) {
|
||||
selected.value = new Set(clients.value.map(rowKey));
|
||||
} else {
|
||||
selected.value = new Set();
|
||||
}
|
||||
}
|
||||
function clearSelection() {
|
||||
selected.value = new Set();
|
||||
}
|
||||
|
||||
watch(clients, (list) => {
|
||||
if (selected.value.size === 0) return;
|
||||
const valid = new Set(list.map(rowKey));
|
||||
const next = new Set();
|
||||
for (const k of selected.value) if (valid.has(k)) next.add(k);
|
||||
if (next.size !== selected.value.size) selected.value = next;
|
||||
});
|
||||
|
||||
const statsClient = ref(null);
|
||||
function openStats(client) {
|
||||
statsClient.value = client;
|
||||
}
|
||||
function closeStats() {
|
||||
statsClient.value = null;
|
||||
}
|
||||
|
||||
function confirmBulkDelete() {
|
||||
const picked = clients.value.filter((c) => selected.value.has(rowKey(c)));
|
||||
if (picked.length === 0) return;
|
||||
|
||||
const total = clients.value.length;
|
||||
const keepLast = picked.length === total;
|
||||
const toDelete = keepLast ? picked.slice(0, -1) : picked;
|
||||
|
||||
if (toDelete.length === 0) {
|
||||
Modal.warning({
|
||||
title: t('pages.inbounds.deleteClient'),
|
||||
content: 'Inbound must keep at least one client — delete the inbound to remove all.',
|
||||
okText: t('confirm'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: `${t('pages.inbounds.deleteClient')} — ${toDelete.length}${keepLast ? ` / ${total}` : ''}`,
|
||||
content: keepLast
|
||||
? 'Inbound must keep at least one client — the last selected will remain. Delete the inbound to remove all.'
|
||||
: t('pages.inbounds.deleteClientContent'),
|
||||
okText: t('delete'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: () => {
|
||||
emit('delete-clients', { dbInbound: props.dbInbound, clients: toDelete });
|
||||
clearSelection();
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="client-list" :class="{ 'is-mobile': isMobile, 'is-dark': isDarkTheme, 'has-select': isRemovable }">
|
||||
<div v-if="isRemovable && selectedCount > 0" class="bulk-bar">
|
||||
<span class="bulk-count">{{ selectedCount }} selected</span>
|
||||
<a-button size="small" type="link" @click="clearSelection">{{ t('cancel') }}</a-button>
|
||||
<a-button size="small" danger @click="confirmBulkDelete">
|
||||
<DeleteOutlined /> {{ t('delete') }}
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- ====================== Desktop: grid table ===================== -->
|
||||
<template v-if="!isMobile">
|
||||
<div class="client-row client-list-header">
|
||||
<div v-if="isRemovable" class="cell cell-select">
|
||||
<a-checkbox :checked="allSelected" :indeterminate="someSelected && !allSelected"
|
||||
@change="(e) => selectAll(e.target.checked)" />
|
||||
</div>
|
||||
<div class="cell cell-actions">{{ t('pages.settings.actions') }}</div>
|
||||
<div class="cell cell-enable">{{ t('enable') }}</div>
|
||||
<div class="cell cell-online">{{ t('online') }}</div>
|
||||
<div class="cell cell-client">{{ t('pages.inbounds.client') }}</div>
|
||||
<div class="cell cell-traffic">{{ t('pages.inbounds.traffic') }}</div>
|
||||
<div class="cell cell-remained">{{ t('remained') }}</div>
|
||||
<div class="cell cell-expiry">{{ t('pages.inbounds.expireDate') }}</div>
|
||||
</div>
|
||||
|
||||
<div v-for="client in paginatedClients" :key="rowKey(client)" class="client-row"
|
||||
:class="{ 'is-selected': isSelected(rowKey(client)) }">
|
||||
<div v-if="isRemovable" class="cell cell-select">
|
||||
<a-checkbox :checked="isSelected(rowKey(client))"
|
||||
@change="(e) => toggleSelect(rowKey(client), e.target.checked)" />
|
||||
</div>
|
||||
<div class="cell cell-actions">
|
||||
<a-tooltip v-if="dbInbound.hasLink()" :title="t('qrCode')">
|
||||
<QrcodeOutlined class="row-icon" @click="emit('qrcode-client', { dbInbound, client })" />
|
||||
</a-tooltip>
|
||||
<a-tooltip :title="t('edit')">
|
||||
<EditOutlined class="row-icon" @click="emit('edit-client', { dbInbound, client })" />
|
||||
</a-tooltip>
|
||||
<a-tooltip :title="t('info')">
|
||||
<InfoCircleOutlined class="row-icon" @click="emit('info-client', { dbInbound, client })" />
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="client.email" :title="t('pages.inbounds.resetTraffic')">
|
||||
<RetweetOutlined class="row-icon" @click="confirmReset(client)" />
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="isRemovable" :title="t('delete')">
|
||||
<DeleteOutlined class="row-icon danger" @click="confirmDelete(client)" />
|
||||
</a-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="cell cell-enable">
|
||||
<a-switch :checked="client.enable" size="small"
|
||||
@change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
|
||||
</div>
|
||||
|
||||
<div class="cell cell-online">
|
||||
<a-popover>
|
||||
<template #content>{{ t('lastOnline') }}: {{ lastOnlineLabel(client.email) }}</template>
|
||||
<a-tag v-if="client.enable && isClientOnline(client.email)" color="green">{{ t('online') }}</a-tag>
|
||||
<a-tag v-else>{{ t('offline') }}</a-tag>
|
||||
</a-popover>
|
||||
</div>
|
||||
|
||||
<div class="cell cell-client">
|
||||
<a-tooltip>
|
||||
<template #title>
|
||||
<template v-if="isClientDepleted(client.email)">{{ t('depleted') }}</template>
|
||||
<template v-else-if="!client.enable">{{ t('disabled') }}</template>
|
||||
<template v-else-if="isClientOnline(client.email)">{{ t('online') }}</template>
|
||||
<template v-else>{{ t('offline') }}</template>
|
||||
</template>
|
||||
<a-badge :color="statusBadgeColor(client)" />
|
||||
</a-tooltip>
|
||||
<div class="client-id-stack">
|
||||
<a-tooltip :title="client.email">
|
||||
<span class="client-email">{{ client.email }}</span>
|
||||
</a-tooltip>
|
||||
<span v-if="client.comment && client.comment.trim()" class="client-comment">
|
||||
{{ client.comment.length > 50 ? client.comment.substring(0, 47) + '…' : client.comment }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cell cell-traffic">
|
||||
<a-popover>
|
||||
<template v-if="client.email" #content>
|
||||
<table cellpadding="2">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>↑ {{ SizeFormatter.sizeFormat(getUp(client.email)) }}</td>
|
||||
<td>↓ {{ SizeFormatter.sizeFormat(getDown(client.email)) }}</td>
|
||||
</tr>
|
||||
<tr v-if="client.totalGB > 0">
|
||||
<td>{{ t('remained') }}</td>
|
||||
<td>{{ SizeFormatter.sizeFormat(getRem(client.email)) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
<div class="usage-bar">
|
||||
<span class="usage-text">{{ SizeFormatter.sizeFormat(getSum(client.email)) }}</span>
|
||||
<a-progress v-if="!client.enable" :stroke-color="isDarkTheme ? 'rgb(72,84,105)' : '#bcbcbc'"
|
||||
:show-info="false" :percent="statsProgress(client.email)" size="small" />
|
||||
<a-progress v-else-if="client.totalGB > 0" :stroke-color="clientStatsColor(client.email)"
|
||||
:show-info="false" :status="isClientDepleted(client.email) ? 'exception' : ''"
|
||||
:percent="statsProgress(client.email)" size="small" />
|
||||
<a-progress v-else :show-info="false" :percent="100" stroke-color="#722ed1" size="small" />
|
||||
<span class="usage-text">
|
||||
<InfinityIcon v-if="isUnlimitedTotal(client)" />
|
||||
<template v-else>{{ totalGbDisplay(client) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
</a-popover>
|
||||
</div>
|
||||
|
||||
<div class="cell cell-remained">
|
||||
<a-tag v-if="isUnlimitedTotal(client)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
|
||||
<InfinityIcon />
|
||||
</a-tag>
|
||||
<a-tag v-else :color="isClientDepleted(client.email) ? 'red' : ''">
|
||||
{{ SizeFormatter.sizeFormat(getRem(client.email)) }}
|
||||
</a-tag>
|
||||
</div>
|
||||
|
||||
<div class="cell cell-expiry">
|
||||
<template v-if="client.expiryTime !== 0 && client.reset > 0">
|
||||
<a-popover>
|
||||
<template #content>
|
||||
<span v-if="client.expiryTime < 0">{{ t('pages.clients.delayedStart') }}</span>
|
||||
<span v-else>{{ IntlUtil.formatDate(client.expiryTime, datepicker) }}</span>
|
||||
</template>
|
||||
<div class="usage-bar">
|
||||
<span class="usage-text">{{ IntlUtil.formatRelativeTime(client.expiryTime) }}</span>
|
||||
<a-progress :show-info="false" :status="isClientDepleted(client.email) ? 'exception' : ''"
|
||||
:percent="expireProgress(client.expiryTime, client.reset)" size="small" />
|
||||
<span class="usage-text">{{ client.reset }}d</span>
|
||||
</div>
|
||||
</a-popover>
|
||||
</template>
|
||||
<a-popover v-else-if="client.expiryTime !== 0">
|
||||
<template #content>
|
||||
<span v-if="client.expiryTime < 0">{{ t('pages.clients.delayedStart') }}</span>
|
||||
<span v-else>{{ IntlUtil.formatDate(client.expiryTime) }}</span>
|
||||
</template>
|
||||
<a-tag :style="{ minWidth: '50px', border: 'none' }"
|
||||
:color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)">
|
||||
{{ IntlUtil.formatRelativeTime(client.expiryTime) }}
|
||||
</a-tag>
|
||||
</a-popover>
|
||||
<a-tag v-else :color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)" :style="{ border: 'none' }"
|
||||
class="infinite-tag">
|
||||
<InfinityIcon />
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ====================== Mobile: card list ======================= -->
|
||||
<template v-else>
|
||||
<div v-for="client in paginatedClients" :key="rowKey(client)" class="client-card"
|
||||
:class="{ 'is-selected': isSelected(rowKey(client)) }">
|
||||
<div class="client-card-head">
|
||||
<a-checkbox v-if="isRemovable" :checked="isSelected(rowKey(client))"
|
||||
@change="(e) => toggleSelect(rowKey(client), e.target.checked)" />
|
||||
<a-tooltip>
|
||||
<template #title>
|
||||
<template v-if="isClientDepleted(client.email)">{{ t('depleted') }}</template>
|
||||
<template v-else-if="!client.enable">{{ t('disabled') }}</template>
|
||||
<template v-else-if="isClientOnline(client.email)">{{ t('online') }}</template>
|
||||
<template v-else>{{ t('offline') }}</template>
|
||||
</template>
|
||||
<a-badge :color="statusBadgeColor(client)" />
|
||||
</a-tooltip>
|
||||
<a-tooltip :title="client.email">
|
||||
<span class="client-email">{{ client.email }}</span>
|
||||
</a-tooltip>
|
||||
<div class="client-card-actions">
|
||||
<a-tooltip :title="t('info')">
|
||||
<InfoCircleOutlined class="row-icon" @click="openStats(client)" />
|
||||
</a-tooltip>
|
||||
<a-switch :checked="client.enable" size="small"
|
||||
@change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
|
||||
<a-dropdown :trigger="['click']" placement="bottomRight">
|
||||
<EllipsisOutlined class="row-icon" @click.prevent />
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item v-if="dbInbound.hasLink()" @click="emit('qrcode-client', { dbInbound, client })">
|
||||
<QrcodeOutlined /> {{ t('qrCode') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="emit('edit-client', { dbInbound, client })">
|
||||
<EditOutlined /> {{ t('edit') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="emit('info-client', { dbInbound, client })">
|
||||
<InfoCircleOutlined /> {{ t('info') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="client.email" @click="confirmReset(client)">
|
||||
<RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="isRemovable" @click="confirmDelete(client)">
|
||||
<DeleteOutlined /> <span class="danger">{{ t('delete') }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-modal :open="!!statsClient" :footer="null" :width="360" centered
|
||||
:title="statsClient ? statsClient.email || t('info') : ''" @cancel="closeStats">
|
||||
<div v-if="statsClient" class="client-card-foot">
|
||||
<div v-if="statsClient.comment && statsClient.comment.trim()" class="client-comment-line">
|
||||
{{ statsClient.comment }}
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
|
||||
<a-tag :color="clientStatsColor(statsClient.email)">
|
||||
{{ SizeFormatter.sizeFormat(getSum(statsClient.email)) }} /
|
||||
<InfinityIcon v-if="isUnlimitedTotal(statsClient)" />
|
||||
<template v-else>{{ totalGbDisplay(statsClient) }}</template>
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('remained') }}</span>
|
||||
<a-tag v-if="isUnlimitedTotal(statsClient)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
|
||||
<InfinityIcon />
|
||||
</a-tag>
|
||||
<a-tag v-else :color="isClientDepleted(statsClient.email) ? 'red' : ''">
|
||||
{{ SizeFormatter.sizeFormat(getRem(statsClient.email)) }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('online') }}</span>
|
||||
<a-tag v-if="statsClient.enable && isClientOnline(statsClient.email)" color="green">{{ t('online')
|
||||
}}</a-tag>
|
||||
<a-tag v-else>{{ t('offline') }}</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.expireDate') }}</span>
|
||||
<a-tag v-if="statsClient.expiryTime > 0"
|
||||
:color="ColorUtils.userExpiryColor(expireDiff, statsClient, isDarkTheme)">
|
||||
{{ IntlUtil.formatRelativeTime(statsClient.expiryTime) }}
|
||||
</a-tag>
|
||||
<a-tag v-else-if="statsClient.expiryTime < 0" color="green">
|
||||
{{ -statsClient.expiryTime / 86400000 }}d ({{ t('pages.clients.delayedStart') }})
|
||||
</a-tag>
|
||||
<a-tag v-else color="purple">
|
||||
<InfinityIcon />
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<a-pagination v-if="pageSize > 0 && clients.length > pageSize" v-model:current="currentPage" :page-size="pageSize"
|
||||
:total="clients.length" :show-size-changer="false" size="small" class="client-list-pagination" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.client-list {
|
||||
margin: -8px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.bulk-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 16px;
|
||||
background: rgba(22, 119, 255, 0.08);
|
||||
border-bottom: 1px solid rgba(22, 119, 255, 0.18);
|
||||
}
|
||||
|
||||
.bulk-count {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.is-selected {
|
||||
background: rgba(22, 119, 255, 0.06);
|
||||
}
|
||||
|
||||
.client-row {
|
||||
display: grid;
|
||||
/* Default — no select column (single-client inbounds). The .has-select
|
||||
* modifier below prepends the 40px checkbox column. */
|
||||
grid-template-columns:
|
||||
140px
|
||||
/* actions */
|
||||
60px
|
||||
/* enable */
|
||||
80px
|
||||
/* online */
|
||||
minmax(160px, 2fr)
|
||||
/* client identity */
|
||||
minmax(160px, 2fr)
|
||||
/* traffic */
|
||||
130px
|
||||
/* remained */
|
||||
140px;
|
||||
/* expiry */
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.12);
|
||||
}
|
||||
|
||||
.client-list.has-select .client-row {
|
||||
grid-template-columns:
|
||||
40px
|
||||
/* select */
|
||||
140px
|
||||
/* actions */
|
||||
60px
|
||||
/* enable */
|
||||
80px
|
||||
/* online */
|
||||
minmax(160px, 2fr)
|
||||
/* client identity */
|
||||
minmax(160px, 2fr)
|
||||
/* traffic */
|
||||
130px
|
||||
/* remained */
|
||||
140px;
|
||||
/* expiry */
|
||||
}
|
||||
|
||||
.client-row:last-child {
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.12);
|
||||
}
|
||||
|
||||
.client-list-header {
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
opacity: 0.65;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
border-top: none;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.cell {
|
||||
min-width: 0;
|
||||
/* allow grid children to shrink instead of overflowing */
|
||||
}
|
||||
|
||||
.cell-select,
|
||||
.cell-actions,
|
||||
.cell-enable,
|
||||
.cell-online,
|
||||
.cell-remained {
|
||||
text-align: center;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cell-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.cell-client {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cell-traffic,
|
||||
.cell-expiry {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.client-list-header .cell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.client-list-header .cell-actions,
|
||||
.client-list-header .cell-client {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Action icons */
|
||||
.row-icon {
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
color: inherit;
|
||||
transition: color 120ms ease;
|
||||
}
|
||||
|
||||
.row-icon:hover {
|
||||
color: var(--ant-color-primary, #1677ff);
|
||||
}
|
||||
|
||||
.row-icon.danger {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
/* Client identity stack (badge + email + comment) */
|
||||
.client-id-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.client-email {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.client-comment {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Traffic / expiry inline bar: text | progress | text */
|
||||
.usage-bar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(50px, auto) minmax(40px, 1fr) minmax(40px, auto);
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.usage-text {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.usage-bar :deep(.ant-progress) {
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.infinite-tag {
|
||||
min-width: 50px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Strip AD-Vue's default expanded-cell padding so the desktop grid
|
||||
* sits flush against the inbound row's left/right edges. */
|
||||
:deep(.ant-table-expanded-row > .ant-table-cell) {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.client-list-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 10px 16px 4px;
|
||||
}
|
||||
|
||||
/* ===== Mobile card list =========================================== */
|
||||
.client-list.is-mobile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.client-card {
|
||||
border: 1px solid rgba(128, 128, 128, 0.18);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
:global(body.dark) .client-card {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.client-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.client-card-head .client-email {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.client-card-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.client-card-actions .row-icon {
|
||||
font-size: 20px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.client-comment-line {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.client-card-foot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.client-card-foot .stat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.client-card-foot .stat-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.6;
|
||||
min-width: 96px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.client-card-foot :deep(.ant-tag) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Bigger status badge for thumb-readable state at a glance. */
|
||||
.client-card-head :deep(.ant-badge-status-dot) {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,185 +0,0 @@
|
|||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { HttpUtil, SizeFormatter, IntlUtil } from '@/utils';
|
||||
import { TLS_FLOW_CONTROL } from '@/models/inbound.js';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
dbInbound: { type: Object, default: null },
|
||||
dbInbounds: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open', 'saved']);
|
||||
|
||||
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||
|
||||
const sourceInboundId = ref(null);
|
||||
const selectedEmails = ref([]);
|
||||
const flow = ref('');
|
||||
const saving = ref(false);
|
||||
|
||||
const sources = computed(() => {
|
||||
if (!props.dbInbound) return [];
|
||||
return props.dbInbounds
|
||||
.filter(
|
||||
(row) =>
|
||||
row.id !== props.dbInbound.id &&
|
||||
typeof row.isMultiUser === 'function' &&
|
||||
row.isMultiUser(),
|
||||
)
|
||||
.map((row) => {
|
||||
let count = 0;
|
||||
try { count = (row.toInbound().clients || []).length; } catch (_e) { /* ignore */ }
|
||||
return { id: row.id, label: `${row.remark || `#${row.id}`} (${row.protocol}, ${count})` };
|
||||
});
|
||||
});
|
||||
|
||||
const sourceInbound = computed(() => {
|
||||
if (!sourceInboundId.value) return null;
|
||||
return props.dbInbounds.find((r) => r.id === sourceInboundId.value) || null;
|
||||
});
|
||||
|
||||
const sourceClients = computed(() => {
|
||||
const sb = sourceInbound.value;
|
||||
if (!sb) return [];
|
||||
let list = [];
|
||||
try { list = sb.toInbound().clients || []; } catch (_e) { /* ignore */ }
|
||||
const stats = new Map((sb.clientStats || []).map((s) => [s.email, s]));
|
||||
return list
|
||||
.filter((c) => c.email)
|
||||
.map((c) => {
|
||||
const s = stats.get(c.email);
|
||||
const used = s ? (s.up || 0) + (s.down || 0) : 0;
|
||||
let expiryLabel = t('unlimited');
|
||||
if (c.expiryTime > 0) expiryLabel = IntlUtil.formatDate(c.expiryTime);
|
||||
else if (c.expiryTime < 0) expiryLabel = `${-c.expiryTime / 86400000}d`;
|
||||
return { email: c.email, trafficLabel: SizeFormatter.sizeFormat(used), expiryLabel };
|
||||
});
|
||||
});
|
||||
|
||||
const showFlow = computed(() => {
|
||||
if (!props.dbInbound) return false;
|
||||
try {
|
||||
const inb = props.dbInbound.toInbound();
|
||||
return !!(inb && typeof inb.canEnableTlsFlow === 'function' && inb.canEnableTlsFlow());
|
||||
} catch (_e) { return false; }
|
||||
});
|
||||
|
||||
const columns = computed(() => [
|
||||
{ title: t('pages.inbounds.email'), dataIndex: 'email', width: 280 },
|
||||
{ title: t('pages.inbounds.traffic'), dataIndex: 'trafficLabel', width: 140 },
|
||||
{ title: t('pages.inbounds.expireDate'), dataIndex: 'expiryLabel', width: 160 },
|
||||
]);
|
||||
|
||||
const rowSelection = computed(() => ({
|
||||
selectedRowKeys: selectedEmails.value,
|
||||
onChange: (keys) => { selectedEmails.value = keys; },
|
||||
}));
|
||||
|
||||
const title = computed(() => {
|
||||
if (!props.dbInbound) return t('pages.clients.copyFromInbound');
|
||||
const target = props.dbInbound.remark || `#${props.dbInbound.id}`;
|
||||
return `${t('pages.clients.copyToInbound')} ${target}`;
|
||||
});
|
||||
|
||||
watch(() => props.open, (next) => {
|
||||
if (!next) return;
|
||||
sourceInboundId.value = null;
|
||||
selectedEmails.value = [];
|
||||
flow.value = '';
|
||||
saving.value = false;
|
||||
});
|
||||
|
||||
watch(sourceInboundId, () => {
|
||||
selectedEmails.value = [];
|
||||
});
|
||||
|
||||
function selectAll() {
|
||||
selectedEmails.value = sourceClients.value.map((c) => c.email);
|
||||
}
|
||||
function clearAll() {
|
||||
selectedEmails.value = [];
|
||||
}
|
||||
|
||||
async function ok() {
|
||||
if (!sourceInboundId.value) {
|
||||
message.error(t('pages.clients.copySelectSourceFirst'));
|
||||
return;
|
||||
}
|
||||
if (!props.dbInbound) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
sourceInboundId: sourceInboundId.value,
|
||||
clientEmails: selectedEmails.value,
|
||||
};
|
||||
if (showFlow.value && flow.value) payload.flow = flow.value;
|
||||
const msg = await HttpUtil.post(
|
||||
`/panel/api/inbounds/${props.dbInbound.id}/copyClients`,
|
||||
payload,
|
||||
);
|
||||
if (!msg?.success) return;
|
||||
const obj = msg.obj || {};
|
||||
const addedCount = (obj.added || []).length;
|
||||
const errorList = obj.errors || [];
|
||||
if (addedCount > 0) {
|
||||
message.success(`${t('pages.clients.copyResultSuccess')}: ${addedCount}`);
|
||||
} else {
|
||||
message.warning(t('pages.clients.copyResultNone'));
|
||||
}
|
||||
if (errorList.length > 0) {
|
||||
message.error(`${t('pages.clients.copyResultErrors')}: ${errorList.join('; ')}`);
|
||||
}
|
||||
emit('saved');
|
||||
emit('update:open', false);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (saving.value) return;
|
||||
emit('update:open', false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="title" :ok-text="t('pages.clients.copySelected')" :cancel-text="t('close')"
|
||||
:confirm-loading="saving" :mask-closable="false" width="720px" @ok="ok" @cancel="close">
|
||||
<a-space direction="vertical" :style="{ width: '100%' }">
|
||||
<div>
|
||||
<div :style="{ marginBottom: '6px' }">{{ t('pages.clients.copySource') }}</div>
|
||||
<a-select v-model:value="sourceInboundId" :style="{ width: '100%' }" allow-clear>
|
||||
<a-select-option v-for="item in sources" :key="item.id" :value="item.id">
|
||||
{{ item.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
|
||||
<div v-if="sourceInboundId">
|
||||
<a-space :style="{ marginBottom: '8px' }">
|
||||
<a-button size="small" @click="selectAll">{{ t('pages.clients.selectAll') }}</a-button>
|
||||
<a-button size="small" @click="clearAll">{{ t('pages.clients.clearAll') }}</a-button>
|
||||
</a-space>
|
||||
<a-table :columns="columns" :data-source="sourceClients" :pagination="false" size="small"
|
||||
:row-key="(r) => r.email" :row-selection="rowSelection" :scroll="{ y: 280 }" />
|
||||
</div>
|
||||
|
||||
<div v-if="showFlow">
|
||||
<div :style="{ marginBottom: '6px' }">{{ t('pages.clients.copyFlowLabel') }}</div>
|
||||
<a-select v-model:value="flow" :style="{ width: '100%' }" allow-clear>
|
||||
<a-select-option value="">{{ t('none') }}</a-select-option>
|
||||
<a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
|
||||
</a-select>
|
||||
<div :style="{ marginTop: '4px', fontSize: '12px', opacity: 0.7 }">
|
||||
{{ t('pages.clients.copyFlowHint') }}
|
||||
</div>
|
||||
</div>
|
||||
</a-space>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
|
@ -9,8 +9,6 @@ import {
|
|||
MoreOutlined,
|
||||
EditOutlined,
|
||||
QrcodeOutlined,
|
||||
UserAddOutlined,
|
||||
UsergroupAddOutlined,
|
||||
CopyOutlined,
|
||||
FileDoneOutlined,
|
||||
ExportOutlined,
|
||||
|
|
@ -21,14 +19,12 @@ import {
|
|||
BlockOutlined,
|
||||
DeleteOutlined,
|
||||
InfoCircleOutlined,
|
||||
RightOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
import { HttpUtil, ObjectUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
|
||||
import { DBInbound } from '@/models/dbinbound.js';
|
||||
import { Inbound } from '@/models/inbound.js';
|
||||
import InfinityIcon from '@/components/InfinityIcon.vue';
|
||||
import ClientRowTable from './ClientRowTable.vue';
|
||||
import { useDatepicker } from '@/composables/useDatepicker.js';
|
||||
|
||||
const { datepicker } = useDatepicker();
|
||||
|
|
@ -58,14 +54,6 @@ const emit = defineEmits([
|
|||
'add-inbound',
|
||||
'general-action',
|
||||
'row-action',
|
||||
// Per-client events surfaced from the expand-row table.
|
||||
'edit-client',
|
||||
'qrcode-client',
|
||||
'info-client',
|
||||
'reset-traffic-client',
|
||||
'delete-client',
|
||||
'delete-clients',
|
||||
'toggle-enable-client',
|
||||
]);
|
||||
|
||||
// ============ Toolbar / search & filter =============================
|
||||
|
|
@ -249,19 +237,6 @@ const desktopColumns = computed(() => {
|
|||
});
|
||||
const columns = computed(() => desktopColumns.value);
|
||||
|
||||
// Mobile expansion state — replaces a-table's expandable() since the
|
||||
// mobile branch renders a hand-rolled card list rather than a table.
|
||||
const expandedIds = ref(new Set());
|
||||
function toggleExpanded(id) {
|
||||
const next = new Set(expandedIds.value);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
expandedIds.value = next;
|
||||
}
|
||||
function isExpanded(id) {
|
||||
return expandedIds.value.has(id);
|
||||
}
|
||||
|
||||
const statsRecord = ref(null);
|
||||
function openStats(record) {
|
||||
statsRecord.value = record;
|
||||
|
|
@ -395,10 +370,8 @@ function showQrCodeMenu(dbInbound) {
|
|||
<div v-if="visibleInbounds.length === 0" class="card-empty">—</div>
|
||||
|
||||
<div v-for="record in sortedInbounds" :key="record.id" class="inbound-card">
|
||||
<!-- Header: chevron (multi-user only) + id + remark + info + enable + actions -->
|
||||
<div class="card-head" @click="record.isMultiUser() && toggleExpanded(record.id)">
|
||||
<RightOutlined v-if="record.isMultiUser()" class="card-expand"
|
||||
:class="{ 'is-expanded': isExpanded(record.id) }" />
|
||||
<!-- Header: id + remark + info + enable + actions -->
|
||||
<div class="card-head">
|
||||
<span class="card-id">#{{ record.id }}</span>
|
||||
<span class="tag-name">{{ record.remark }}</span>
|
||||
<div class="card-actions" @click.stop>
|
||||
|
|
@ -417,15 +390,6 @@ function showQrCodeMenu(dbInbound) {
|
|||
<QrcodeOutlined /> {{ t('qrCode') }}
|
||||
</a-menu-item>
|
||||
<template v-if="record.isMultiUser()">
|
||||
<a-menu-item key="addClient">
|
||||
<UserAddOutlined /> {{ t('pages.clients.add') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="addBulkClient">
|
||||
<UsergroupAddOutlined /> {{ t('pages.clients.bulk') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="copyClients">
|
||||
<CopyOutlined /> {{ t('pages.clients.copyFromInbound') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="resetClients">
|
||||
<FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
|
||||
</a-menu-item>
|
||||
|
|
@ -461,18 +425,6 @@ function showQrCodeMenu(dbInbound) {
|
|||
</a-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded client list (multi-user only) -->
|
||||
<div v-if="record.isMultiUser() && isExpanded(record.id)" class="card-clients">
|
||||
<ClientRowTable :db-inbound="record" :is-mobile="true" :traffic-diff="trafficDiff" :expire-diff="expireDiff"
|
||||
:online-clients="onlineClients" :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme"
|
||||
:page-size="pageSize" :total-client-count="clientCount[record.id]?.clients || 0"
|
||||
:stats-version="statsVersion" @edit-client="(p) => emit('edit-client', p)"
|
||||
@qrcode-client="(p) => emit('qrcode-client', p)" @info-client="(p) => emit('info-client', p)"
|
||||
@reset-traffic-client="(p) => emit('reset-traffic-client', p)"
|
||||
@delete-client="(p) => emit('delete-client', p)" @delete-clients="(p) => emit('delete-clients', p)"
|
||||
@toggle-enable-client="(p) => emit('toggle-enable-client', p)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -542,21 +494,7 @@ function showQrCodeMenu(dbInbound) {
|
|||
<!-- ====================== Desktop: a-table ======================== -->
|
||||
<a-table v-else :columns="columns" :data-source="sortedInbounds" :row-key="(r) => r.id"
|
||||
:pagination="paginationFor(sortedInbounds)" :scroll="{ x: 1000 }" :style="{ marginTop: '10px' }" size="small"
|
||||
:row-class-name="(r) => (r.isMultiUser() ? '' : 'hide-expand-icon')" @change="onTableChange">
|
||||
<!-- Per-inbound client list, expanded by clicking the row's
|
||||
default expand chevron. Hidden via row-class-name for
|
||||
non-multi-user inbounds (matches legacy behavior). -->
|
||||
<template #expandedRowRender="{ record }">
|
||||
<ClientRowTable v-if="record.isMultiUser()" :db-inbound="record" :is-mobile="isMobile"
|
||||
:traffic-diff="trafficDiff" :expire-diff="expireDiff" :online-clients="onlineClients"
|
||||
:last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme" :page-size="pageSize"
|
||||
:total-client-count="clientCount[record.id]?.clients || 0" :stats-version="statsVersion"
|
||||
@edit-client="(p) => emit('edit-client', p)" @qrcode-client="(p) => emit('qrcode-client', p)"
|
||||
@info-client="(p) => emit('info-client', p)" @reset-traffic-client="(p) => emit('reset-traffic-client', p)"
|
||||
@delete-client="(p) => emit('delete-client', p)" @delete-clients="(p) => emit('delete-clients', p)"
|
||||
@toggle-enable-client="(p) => emit('toggle-enable-client', p)" />
|
||||
</template>
|
||||
|
||||
@change="onTableChange">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<!-- ============== Action dropdown ============== -->
|
||||
<template v-if="column.key === 'action'">
|
||||
|
|
@ -579,15 +517,6 @@ function showQrCodeMenu(dbInbound) {
|
|||
<QrcodeOutlined /> {{ t('qrCode') }}
|
||||
</a-menu-item>
|
||||
<template v-if="record.isMultiUser()">
|
||||
<a-menu-item key="addClient">
|
||||
<UserAddOutlined /> {{ t('pages.clients.add') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="addBulkClient">
|
||||
<UsergroupAddOutlined /> {{ t('pages.clients.bulk') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="copyClients">
|
||||
<CopyOutlined /> {{ t('pages.clients.copyFromInbound') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="resetClients">
|
||||
<FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
|
||||
</a-menu-item>
|
||||
|
|
@ -789,23 +718,6 @@ function showQrCodeMenu(dbInbound) {
|
|||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
/* Hide the expand chevron on rows whose inbound has no client list
|
||||
* (HTTP/Mixed/Tunnel/WireGuard single-config). */
|
||||
:deep(.hide-expand-icon .ant-table-row-expand-icon) {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* Push the expand chevron away from the table's left edge so it has
|
||||
* a little breathing room instead of being flush against the corner. */
|
||||
:deep(.ant-table-tbody .ant-table-cell-with-append) {
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
:deep(.ant-table-row-expand-icon) {
|
||||
margin-inline-end: 10px;
|
||||
margin-inline-start: 4px;
|
||||
}
|
||||
|
||||
/* Round the table's outer corners — AD-Vue gives .ant-table the radius
|
||||
* token, but the inner header strip and footer touch the edges, so clip
|
||||
* them here. */
|
||||
|
|
@ -890,17 +802,6 @@ function showQrCodeMenu(dbInbound) {
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-expand {
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
transition: transform 150ms ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-expand.is-expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.card-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -927,11 +828,6 @@ function showQrCodeMenu(dbInbound) {
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.card-clients {
|
||||
margin-top: 4px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
.card-empty {
|
||||
text-align: center;
|
||||
|
|
|
|||
|
|
@ -18,9 +18,6 @@ import CustomStatistic from '@/components/CustomStatistic.vue';
|
|||
import { useNodeList } from '@/composables/useNodeList.js';
|
||||
import InboundList from './InboundList.vue';
|
||||
import InboundFormModal from './InboundFormModal.vue';
|
||||
import ClientFormModal from './ClientFormModal.vue';
|
||||
import ClientBulkModal from './ClientBulkModal.vue';
|
||||
import CopyClientsModal from './CopyClientsModal.vue';
|
||||
import InboundInfoModal from './InboundInfoModal.vue';
|
||||
import QrCodeModal from './QrCodeModal.vue';
|
||||
import TextModal from '@/components/TextModal.vue';
|
||||
|
|
@ -81,17 +78,6 @@ const formOpen = ref(false);
|
|||
const formMode = ref('add');
|
||||
const formDbInbound = ref(null);
|
||||
|
||||
// === Client modal (single + bulk) =====================================
|
||||
const clientOpen = ref(false);
|
||||
const clientMode = ref('add');
|
||||
const clientDbInbound = ref(null);
|
||||
const clientIndex = ref(null);
|
||||
|
||||
const bulkOpen = ref(false);
|
||||
const bulkDbInbound = ref(null);
|
||||
const copyOpen = ref(false);
|
||||
const copyDbInbound = ref(null);
|
||||
|
||||
// === Info / QR-code modals ===========================================
|
||||
const infoOpen = ref(false);
|
||||
const infoDbInbound = ref(null);
|
||||
|
|
@ -283,73 +269,6 @@ function findClientIndex(dbInbound, client) {
|
|||
return idx >= 0 ? idx : 0;
|
||||
}
|
||||
|
||||
function getClientId(protocol, client) {
|
||||
switch (protocol) {
|
||||
case 'trojan': return client.password;
|
||||
case 'shadowsocks': return client.email;
|
||||
case 'hysteria': return client.auth;
|
||||
default: return client.id;
|
||||
}
|
||||
}
|
||||
|
||||
// === Per-client handlers (called from the expand-row table) =========
|
||||
function onEditClient({ dbInbound, client }) {
|
||||
clientMode.value = 'edit';
|
||||
clientDbInbound.value = dbInbound;
|
||||
clientIndex.value = findClientIndex(dbInbound, client);
|
||||
clientOpen.value = true;
|
||||
}
|
||||
|
||||
function onQrcodeClient({ dbInbound, client }) {
|
||||
qrDbInbound.value = checkFallback(dbInbound);
|
||||
qrClient.value = client || null;
|
||||
qrOpen.value = true;
|
||||
}
|
||||
|
||||
function onInfoClient({ dbInbound, client }) {
|
||||
infoDbInbound.value = checkFallback(dbInbound);
|
||||
infoClientIndex.value = findClientIndex(dbInbound, client);
|
||||
infoOpen.value = true;
|
||||
}
|
||||
|
||||
async function onResetTrafficClient({ dbInbound, client }) {
|
||||
const msg = await HttpUtil.post(
|
||||
`/panel/api/inbounds/${dbInbound.id}/resetClientTraffic/${client.email}`,
|
||||
);
|
||||
if (msg?.success) await refresh();
|
||||
}
|
||||
|
||||
async function onDeleteClient({ dbInbound, client }) {
|
||||
const clientId = getClientId(dbInbound.protocol, client);
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/delClient/${clientId}`);
|
||||
if (msg?.success) await refresh();
|
||||
}
|
||||
|
||||
async function onDeleteClients({ dbInbound, clients }) {
|
||||
for (const client of clients) {
|
||||
const clientId = getClientId(dbInbound.protocol, client);
|
||||
await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/delClient/${clientId}`);
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function onToggleEnableClient({ dbInbound, client, next }) {
|
||||
// Mirror legacy: clone the parsed inbound, flip enable on the matching
|
||||
// client, and post the whole client back through updateClient. This
|
||||
// keeps the wire shape identical to the modal save path.
|
||||
const inbound = dbInbound.toInbound();
|
||||
const clients = inbound?.clients || [];
|
||||
const idx = findClientIndex(dbInbound, client);
|
||||
if (idx < 0 || !clients[idx]) return;
|
||||
clients[idx].enable = next;
|
||||
const clientId = getClientId(dbInbound.protocol, clients[idx]);
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/updateClient/${clientId}`, {
|
||||
id: dbInbound.id,
|
||||
settings: `{"clients": [${clients[idx].toString()}]}`,
|
||||
});
|
||||
if (msg?.success) await refresh();
|
||||
}
|
||||
|
||||
function onAddInbound() {
|
||||
formMode.value = 'add';
|
||||
formDbInbound.value = null;
|
||||
|
|
@ -362,18 +281,6 @@ function openEdit(dbInbound) {
|
|||
formOpen.value = true;
|
||||
}
|
||||
|
||||
function openAddClient(dbInbound) {
|
||||
clientMode.value = 'add';
|
||||
clientDbInbound.value = dbInbound;
|
||||
clientIndex.value = null;
|
||||
clientOpen.value = true;
|
||||
}
|
||||
|
||||
function openAddBulkClient(dbInbound) {
|
||||
bulkDbInbound.value = dbInbound;
|
||||
bulkOpen.value = true;
|
||||
}
|
||||
|
||||
// Per-row destructive actions go through Modal.confirm (matches legacy).
|
||||
function confirmDelete(dbInbound) {
|
||||
Modal.confirm({
|
||||
|
|
@ -492,12 +399,6 @@ function onRowAction({ key, dbInbound }) {
|
|||
case 'edit':
|
||||
openEdit(dbInbound);
|
||||
break;
|
||||
case 'addClient':
|
||||
openAddClient(dbInbound);
|
||||
break;
|
||||
case 'addBulkClient':
|
||||
openAddBulkClient(dbInbound);
|
||||
break;
|
||||
case 'showInfo':
|
||||
infoDbInbound.value = checkFallback(dbInbound);
|
||||
infoClientIndex.value = findClientIndex(dbInbound, null);
|
||||
|
|
@ -517,10 +418,6 @@ function onRowAction({ key, dbInbound }) {
|
|||
case 'clipboard':
|
||||
exportInboundClipboard(dbInbound);
|
||||
break;
|
||||
case 'copyClients':
|
||||
copyDbInbound.value = dbInbound;
|
||||
copyOpen.value = true;
|
||||
break;
|
||||
case 'delete':
|
||||
confirmDelete(dbInbound);
|
||||
break;
|
||||
|
|
@ -642,10 +539,7 @@ function onRowAction({ key, dbInbound }) {
|
|||
:sub-enable="subSettings.enable" :nodes-by-id="nodesById" :has-active-node="hasActiveNode"
|
||||
:stats-version="statsVersion"
|
||||
@refresh="refresh"
|
||||
@add-inbound="onAddInbound" @general-action="onGeneralAction" @row-action="onRowAction"
|
||||
@edit-client="onEditClient" @qrcode-client="onQrcodeClient" @info-client="onInfoClient"
|
||||
@reset-traffic-client="onResetTrafficClient" @delete-client="onDeleteClient"
|
||||
@delete-clients="onDeleteClients" @toggle-enable-client="onToggleEnableClient" />
|
||||
@add-inbound="onAddInbound" @general-action="onGeneralAction" @row-action="onRowAction" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-spin>
|
||||
|
|
@ -654,13 +548,6 @@ function onRowAction({ key, dbInbound }) {
|
|||
|
||||
<InboundFormModal v-model:open="formOpen" :mode="formMode" :db-inbound="formDbInbound"
|
||||
:db-inbounds="dbInbounds" @saved="refresh" />
|
||||
<ClientFormModal v-model:open="clientOpen" :mode="clientMode" :db-inbound="clientDbInbound"
|
||||
:client-index="clientIndex" :sub-enable="subSettings.enable" :tg-bot-enable="tgBotEnable"
|
||||
:ip-limit-enable="ipLimitEnable" :traffic-diff="trafficDiff" @saved="refresh" />
|
||||
<ClientBulkModal v-model:open="bulkOpen" :db-inbound="bulkDbInbound" :sub-enable="subSettings.enable"
|
||||
:tg-bot-enable="tgBotEnable" :ip-limit-enable="ipLimitEnable" @saved="refresh" />
|
||||
<CopyClientsModal v-model:open="copyOpen" :db-inbound="copyDbInbound" :db-inbounds="dbInbounds"
|
||||
@saved="refresh" />
|
||||
<InboundInfoModal v-model:open="infoOpen" :db-inbound="infoDbInbound" :client-index="infoClientIndex"
|
||||
:remark-model="remarkModel" :expire-diff="expireDiff" :traffic-diff="trafficDiff"
|
||||
:ip-limit-enable="ipLimitEnable" :tg-bot-enable="tgBotEnable" :sub-settings="subSettings"
|
||||
|
|
|
|||
Loading…
Reference in a new issue