mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 13:14:11 +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,
|
InfoCircleOutlined,
|
||||||
QrcodeOutlined,
|
QrcodeOutlined,
|
||||||
RetweetOutlined,
|
RetweetOutlined,
|
||||||
ControlOutlined,
|
|
||||||
DownOutlined,
|
|
||||||
MoreOutlined,
|
MoreOutlined,
|
||||||
UsergroupAddOutlined,
|
UsergroupAddOutlined,
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
|
|
@ -218,15 +216,14 @@ function onShowQr(row) {
|
||||||
|
|
||||||
function onResetAllTraffics() {
|
function onResetAllTraffics() {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: t('pages.clients.resetAllTrafficsTitle') || 'Reset all client traffic?',
|
title: t('pages.clients.resetAllTrafficsTitle'),
|
||||||
content: t('pages.clients.resetAllTrafficsContent')
|
content: t('pages.clients.resetAllTrafficsContent'),
|
||||||
|| 'Every client’s up/down counter drops to zero. Quotas and expiry are not affected.',
|
|
||||||
okText: t('reset') || 'Reset',
|
okText: t('reset') || 'Reset',
|
||||||
okType: 'danger',
|
okType: 'danger',
|
||||||
cancelText: t('cancel'),
|
cancelText: t('cancel'),
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
const msg = await resetAllTraffics();
|
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 })
|
{{ t('pages.clients.deleteSelected', { count: selectedRowKeys.length })
|
||||||
|| `Delete (${selectedRowKeys.length})` }}
|
|| `Delete (${selectedRowKeys.length})` }}
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-dropdown :trigger="['click']">
|
<a-button size="small" @click="onResetAllTraffics">
|
||||||
<a-button size="small">
|
<template #icon>
|
||||||
<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 />
|
<RetweetOutlined />
|
||||||
<span style="margin-left: 6px">
|
|
||||||
{{ t('pages.clients.resetAllTraffics') }}
|
|
||||||
</span>
|
|
||||||
</a-menu-item>
|
|
||||||
</a-menu>
|
|
||||||
</template>
|
</template>
|
||||||
</a-dropdown>
|
<template v-if="!isMobile">{{ t('pages.clients.resetAllTraffics') }}</template>
|
||||||
|
</a-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -462,7 +448,8 @@ const columns = computed(() => [
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<a-checkbox :checked="isSelected(row.id)"
|
<a-checkbox :checked="isSelected(row.id)"
|
||||||
@change="(e) => toggleSelect(row.id, e.target.checked)" />
|
@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>
|
<span class="tag-name">{{ row.email }}</span>
|
||||||
<div class="card-actions" @click.stop>
|
<div class="card-actions" @click.stop>
|
||||||
<a-tooltip :title="t('pages.clients.moreInformation') || 'Info'">
|
<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,
|
MoreOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
QrcodeOutlined,
|
QrcodeOutlined,
|
||||||
UserAddOutlined,
|
|
||||||
UsergroupAddOutlined,
|
|
||||||
CopyOutlined,
|
CopyOutlined,
|
||||||
FileDoneOutlined,
|
FileDoneOutlined,
|
||||||
ExportOutlined,
|
ExportOutlined,
|
||||||
|
|
@ -21,14 +19,12 @@ import {
|
||||||
BlockOutlined,
|
BlockOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
InfoCircleOutlined,
|
InfoCircleOutlined,
|
||||||
RightOutlined,
|
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
|
|
||||||
import { HttpUtil, ObjectUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
|
import { HttpUtil, ObjectUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
|
||||||
import { DBInbound } from '@/models/dbinbound.js';
|
import { DBInbound } from '@/models/dbinbound.js';
|
||||||
import { Inbound } from '@/models/inbound.js';
|
import { Inbound } from '@/models/inbound.js';
|
||||||
import InfinityIcon from '@/components/InfinityIcon.vue';
|
import InfinityIcon from '@/components/InfinityIcon.vue';
|
||||||
import ClientRowTable from './ClientRowTable.vue';
|
|
||||||
import { useDatepicker } from '@/composables/useDatepicker.js';
|
import { useDatepicker } from '@/composables/useDatepicker.js';
|
||||||
|
|
||||||
const { datepicker } = useDatepicker();
|
const { datepicker } = useDatepicker();
|
||||||
|
|
@ -58,14 +54,6 @@ const emit = defineEmits([
|
||||||
'add-inbound',
|
'add-inbound',
|
||||||
'general-action',
|
'general-action',
|
||||||
'row-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 =============================
|
// ============ Toolbar / search & filter =============================
|
||||||
|
|
@ -249,19 +237,6 @@ const desktopColumns = computed(() => {
|
||||||
});
|
});
|
||||||
const columns = computed(() => desktopColumns.value);
|
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);
|
const statsRecord = ref(null);
|
||||||
function openStats(record) {
|
function openStats(record) {
|
||||||
statsRecord.value = record;
|
statsRecord.value = record;
|
||||||
|
|
@ -395,10 +370,8 @@ function showQrCodeMenu(dbInbound) {
|
||||||
<div v-if="visibleInbounds.length === 0" class="card-empty">—</div>
|
<div v-if="visibleInbounds.length === 0" class="card-empty">—</div>
|
||||||
|
|
||||||
<div v-for="record in sortedInbounds" :key="record.id" class="inbound-card">
|
<div v-for="record in sortedInbounds" :key="record.id" class="inbound-card">
|
||||||
<!-- Header: chevron (multi-user only) + id + remark + info + enable + actions -->
|
<!-- Header: id + remark + info + enable + actions -->
|
||||||
<div class="card-head" @click="record.isMultiUser() && toggleExpanded(record.id)">
|
<div class="card-head">
|
||||||
<RightOutlined v-if="record.isMultiUser()" class="card-expand"
|
|
||||||
:class="{ 'is-expanded': isExpanded(record.id) }" />
|
|
||||||
<span class="card-id">#{{ record.id }}</span>
|
<span class="card-id">#{{ record.id }}</span>
|
||||||
<span class="tag-name">{{ record.remark }}</span>
|
<span class="tag-name">{{ record.remark }}</span>
|
||||||
<div class="card-actions" @click.stop>
|
<div class="card-actions" @click.stop>
|
||||||
|
|
@ -417,15 +390,6 @@ function showQrCodeMenu(dbInbound) {
|
||||||
<QrcodeOutlined /> {{ t('qrCode') }}
|
<QrcodeOutlined /> {{ t('qrCode') }}
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
<template v-if="record.isMultiUser()">
|
<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">
|
<a-menu-item key="resetClients">
|
||||||
<FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
|
<FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
|
|
@ -461,18 +425,6 @@ function showQrCodeMenu(dbInbound) {
|
||||||
</a-dropdown>
|
</a-dropdown>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -542,21 +494,7 @@ function showQrCodeMenu(dbInbound) {
|
||||||
<!-- ====================== Desktop: a-table ======================== -->
|
<!-- ====================== Desktop: a-table ======================== -->
|
||||||
<a-table v-else :columns="columns" :data-source="sortedInbounds" :row-key="(r) => r.id"
|
<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"
|
:pagination="paginationFor(sortedInbounds)" :scroll="{ x: 1000 }" :style="{ marginTop: '10px' }" size="small"
|
||||||
:row-class-name="(r) => (r.isMultiUser() ? '' : 'hide-expand-icon')" @change="onTableChange">
|
@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>
|
|
||||||
|
|
||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="{ column, record }">
|
||||||
<!-- ============== Action dropdown ============== -->
|
<!-- ============== Action dropdown ============== -->
|
||||||
<template v-if="column.key === 'action'">
|
<template v-if="column.key === 'action'">
|
||||||
|
|
@ -579,15 +517,6 @@ function showQrCodeMenu(dbInbound) {
|
||||||
<QrcodeOutlined /> {{ t('qrCode') }}
|
<QrcodeOutlined /> {{ t('qrCode') }}
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
<template v-if="record.isMultiUser()">
|
<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">
|
<a-menu-item key="resetClients">
|
||||||
<FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
|
<FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
|
|
@ -789,23 +718,6 @@ function showQrCodeMenu(dbInbound) {
|
||||||
color: #ff4d4f;
|
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
|
/* 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
|
* token, but the inner header strip and footer touch the edges, so clip
|
||||||
* them here. */
|
* them here. */
|
||||||
|
|
@ -890,17 +802,6 @@ function showQrCodeMenu(dbInbound) {
|
||||||
flex-shrink: 0;
|
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 {
|
.card-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -927,11 +828,6 @@ function showQrCodeMenu(dbInbound) {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-clients {
|
|
||||||
margin-top: 4px;
|
|
||||||
padding-top: 8px;
|
|
||||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-empty {
|
.card-empty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,6 @@ import CustomStatistic from '@/components/CustomStatistic.vue';
|
||||||
import { useNodeList } from '@/composables/useNodeList.js';
|
import { useNodeList } from '@/composables/useNodeList.js';
|
||||||
import InboundList from './InboundList.vue';
|
import InboundList from './InboundList.vue';
|
||||||
import InboundFormModal from './InboundFormModal.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 InboundInfoModal from './InboundInfoModal.vue';
|
||||||
import QrCodeModal from './QrCodeModal.vue';
|
import QrCodeModal from './QrCodeModal.vue';
|
||||||
import TextModal from '@/components/TextModal.vue';
|
import TextModal from '@/components/TextModal.vue';
|
||||||
|
|
@ -81,17 +78,6 @@ const formOpen = ref(false);
|
||||||
const formMode = ref('add');
|
const formMode = ref('add');
|
||||||
const formDbInbound = ref(null);
|
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 ===========================================
|
// === Info / QR-code modals ===========================================
|
||||||
const infoOpen = ref(false);
|
const infoOpen = ref(false);
|
||||||
const infoDbInbound = ref(null);
|
const infoDbInbound = ref(null);
|
||||||
|
|
@ -283,73 +269,6 @@ function findClientIndex(dbInbound, client) {
|
||||||
return idx >= 0 ? idx : 0;
|
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() {
|
function onAddInbound() {
|
||||||
formMode.value = 'add';
|
formMode.value = 'add';
|
||||||
formDbInbound.value = null;
|
formDbInbound.value = null;
|
||||||
|
|
@ -362,18 +281,6 @@ function openEdit(dbInbound) {
|
||||||
formOpen.value = true;
|
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).
|
// Per-row destructive actions go through Modal.confirm (matches legacy).
|
||||||
function confirmDelete(dbInbound) {
|
function confirmDelete(dbInbound) {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
|
|
@ -492,12 +399,6 @@ function onRowAction({ key, dbInbound }) {
|
||||||
case 'edit':
|
case 'edit':
|
||||||
openEdit(dbInbound);
|
openEdit(dbInbound);
|
||||||
break;
|
break;
|
||||||
case 'addClient':
|
|
||||||
openAddClient(dbInbound);
|
|
||||||
break;
|
|
||||||
case 'addBulkClient':
|
|
||||||
openAddBulkClient(dbInbound);
|
|
||||||
break;
|
|
||||||
case 'showInfo':
|
case 'showInfo':
|
||||||
infoDbInbound.value = checkFallback(dbInbound);
|
infoDbInbound.value = checkFallback(dbInbound);
|
||||||
infoClientIndex.value = findClientIndex(dbInbound, null);
|
infoClientIndex.value = findClientIndex(dbInbound, null);
|
||||||
|
|
@ -517,10 +418,6 @@ function onRowAction({ key, dbInbound }) {
|
||||||
case 'clipboard':
|
case 'clipboard':
|
||||||
exportInboundClipboard(dbInbound);
|
exportInboundClipboard(dbInbound);
|
||||||
break;
|
break;
|
||||||
case 'copyClients':
|
|
||||||
copyDbInbound.value = dbInbound;
|
|
||||||
copyOpen.value = true;
|
|
||||||
break;
|
|
||||||
case 'delete':
|
case 'delete':
|
||||||
confirmDelete(dbInbound);
|
confirmDelete(dbInbound);
|
||||||
break;
|
break;
|
||||||
|
|
@ -642,10 +539,7 @@ function onRowAction({ key, dbInbound }) {
|
||||||
:sub-enable="subSettings.enable" :nodes-by-id="nodesById" :has-active-node="hasActiveNode"
|
:sub-enable="subSettings.enable" :nodes-by-id="nodesById" :has-active-node="hasActiveNode"
|
||||||
:stats-version="statsVersion"
|
:stats-version="statsVersion"
|
||||||
@refresh="refresh"
|
@refresh="refresh"
|
||||||
@add-inbound="onAddInbound" @general-action="onGeneralAction" @row-action="onRowAction"
|
@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" />
|
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
</a-spin>
|
</a-spin>
|
||||||
|
|
@ -654,13 +548,6 @@ function onRowAction({ key, dbInbound }) {
|
||||||
|
|
||||||
<InboundFormModal v-model:open="formOpen" :mode="formMode" :db-inbound="formDbInbound"
|
<InboundFormModal v-model:open="formOpen" :mode="formMode" :db-inbound="formDbInbound"
|
||||||
:db-inbounds="dbInbounds" @saved="refresh" />
|
: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"
|
<InboundInfoModal v-model:open="infoOpen" :db-inbound="infoDbInbound" :client-index="infoClientIndex"
|
||||||
:remark-model="remarkModel" :expire-diff="expireDiff" :traffic-diff="trafficDiff"
|
:remark-model="remarkModel" :expire-diff="expireDiff" :traffic-diff="trafficDiff"
|
||||||
:ip-limit-enable="ipLimitEnable" :tg-bot-enable="tgBotEnable" :sub-settings="subSettings"
|
:ip-limit-enable="ipLimitEnable" :tg-bot-enable="tgBotEnable" :sub-settings="subSettings"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue