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:
MHSanaei 2026-05-17 09:40:30 +02:00
parent 93ede81094
commit c5217b9a78
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
7 changed files with 14 additions and 1921 deletions

View file

@ -10,8 +10,6 @@ import {
InfoCircleOutlined,
QrcodeOutlined,
RetweetOutlined,
ControlOutlined,
DownOutlined,
MoreOutlined,
UsergroupAddOutlined,
} from '@ant-design/icons-vue';
@ -218,15 +216,14 @@ function onShowQr(row) {
function onResetAllTraffics() {
Modal.confirm({
title: t('pages.clients.resetAllTrafficsTitle') || 'Reset all client traffic?',
content: t('pages.clients.resetAllTrafficsContent')
|| 'Every clients up/down counter drops to zero. Quotas and expiry are not affected.',
title: t('pages.clients.resetAllTrafficsTitle'),
content: t('pages.clients.resetAllTrafficsContent'),
okText: t('reset') || 'Reset',
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await resetAllTraffics();
if (msg?.success) message.success(t('pages.clients.toasts.allTrafficsReset') || 'All client traffic reset');
if (msg?.success) message.success(t('pages.clients.toasts.allTrafficsReset'));
},
});
}
@ -340,23 +337,12 @@ const columns = computed(() => [
{{ t('pages.clients.deleteSelected', { count: selectedRowKeys.length })
|| `Delete (${selectedRowKeys.length})` }}
</a-button>
<a-dropdown :trigger="['click']">
<a-button size="small">
<ControlOutlined />
<span v-if="!isMobile">{{ t('pages.clients.general') }}</span>
<DownOutlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item key="resetAllTraffics" @click="onResetAllTraffics">
<RetweetOutlined />
<span style="margin-left: 6px">
{{ t('pages.clients.resetAllTraffics') }}
</span>
</a-menu-item>
</a-menu>
<a-button size="small" @click="onResetAllTraffics">
<template #icon>
<RetweetOutlined />
</template>
</a-dropdown>
<template v-if="!isMobile">{{ t('pages.clients.resetAllTraffics') }}</template>
</a-button>
</div>
</template>
@ -462,7 +448,8 @@ const columns = computed(() => [
<div class="card-head">
<a-checkbox :checked="isSelected(row.id)"
@change="(e) => toggleSelect(row.id, e.target.checked)" />
<a-badge :color="row.enable && isOnline(row.email) ? 'green' : (row.enable ? 'default' : 'red')" />
<a-badge
:color="row.enable && isOnline(row.email) ? 'green' : (row.enable ? 'default' : 'red')" />
<span class="tag-name">{{ row.email }}</span>
<div class="card-actions" @click.stop>
<a-tooltip :title="t('pages.clients.moreInformation') || 'Info'">

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -9,8 +9,6 @@ import {
MoreOutlined,
EditOutlined,
QrcodeOutlined,
UserAddOutlined,
UsergroupAddOutlined,
CopyOutlined,
FileDoneOutlined,
ExportOutlined,
@ -21,14 +19,12 @@ import {
BlockOutlined,
DeleteOutlined,
InfoCircleOutlined,
RightOutlined,
} from '@ant-design/icons-vue';
import { HttpUtil, ObjectUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
import { DBInbound } from '@/models/dbinbound.js';
import { Inbound } from '@/models/inbound.js';
import InfinityIcon from '@/components/InfinityIcon.vue';
import ClientRowTable from './ClientRowTable.vue';
import { useDatepicker } from '@/composables/useDatepicker.js';
const { datepicker } = useDatepicker();
@ -58,14 +54,6 @@ const emit = defineEmits([
'add-inbound',
'general-action',
'row-action',
// Per-client events surfaced from the expand-row table.
'edit-client',
'qrcode-client',
'info-client',
'reset-traffic-client',
'delete-client',
'delete-clients',
'toggle-enable-client',
]);
// ============ Toolbar / search & filter =============================
@ -249,19 +237,6 @@ const desktopColumns = computed(() => {
});
const columns = computed(() => desktopColumns.value);
// Mobile expansion state replaces a-table's expandable() since the
// mobile branch renders a hand-rolled card list rather than a table.
const expandedIds = ref(new Set());
function toggleExpanded(id) {
const next = new Set(expandedIds.value);
if (next.has(id)) next.delete(id);
else next.add(id);
expandedIds.value = next;
}
function isExpanded(id) {
return expandedIds.value.has(id);
}
const statsRecord = ref(null);
function openStats(record) {
statsRecord.value = record;
@ -395,10 +370,8 @@ function showQrCodeMenu(dbInbound) {
<div v-if="visibleInbounds.length === 0" class="card-empty"></div>
<div v-for="record in sortedInbounds" :key="record.id" class="inbound-card">
<!-- Header: chevron (multi-user only) + id + remark + info + enable + actions -->
<div class="card-head" @click="record.isMultiUser() && toggleExpanded(record.id)">
<RightOutlined v-if="record.isMultiUser()" class="card-expand"
:class="{ 'is-expanded': isExpanded(record.id) }" />
<!-- Header: id + remark + info + enable + actions -->
<div class="card-head">
<span class="card-id">#{{ record.id }}</span>
<span class="tag-name">{{ record.remark }}</span>
<div class="card-actions" @click.stop>
@ -417,15 +390,6 @@ function showQrCodeMenu(dbInbound) {
<QrcodeOutlined /> {{ t('qrCode') }}
</a-menu-item>
<template v-if="record.isMultiUser()">
<a-menu-item key="addClient">
<UserAddOutlined /> {{ t('pages.clients.add') }}
</a-menu-item>
<a-menu-item key="addBulkClient">
<UsergroupAddOutlined /> {{ t('pages.clients.bulk') }}
</a-menu-item>
<a-menu-item key="copyClients">
<CopyOutlined /> {{ t('pages.clients.copyFromInbound') }}
</a-menu-item>
<a-menu-item key="resetClients">
<FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
</a-menu-item>
@ -461,18 +425,6 @@ function showQrCodeMenu(dbInbound) {
</a-dropdown>
</div>
</div>
<!-- Expanded client list (multi-user only) -->
<div v-if="record.isMultiUser() && isExpanded(record.id)" class="card-clients">
<ClientRowTable :db-inbound="record" :is-mobile="true" :traffic-diff="trafficDiff" :expire-diff="expireDiff"
:online-clients="onlineClients" :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme"
:page-size="pageSize" :total-client-count="clientCount[record.id]?.clients || 0"
:stats-version="statsVersion" @edit-client="(p) => emit('edit-client', p)"
@qrcode-client="(p) => emit('qrcode-client', p)" @info-client="(p) => emit('info-client', p)"
@reset-traffic-client="(p) => emit('reset-traffic-client', p)"
@delete-client="(p) => emit('delete-client', p)" @delete-clients="(p) => emit('delete-clients', p)"
@toggle-enable-client="(p) => emit('toggle-enable-client', p)" />
</div>
</div>
</div>
@ -542,21 +494,7 @@ function showQrCodeMenu(dbInbound) {
<!-- ====================== Desktop: a-table ======================== -->
<a-table v-else :columns="columns" :data-source="sortedInbounds" :row-key="(r) => r.id"
:pagination="paginationFor(sortedInbounds)" :scroll="{ x: 1000 }" :style="{ marginTop: '10px' }" size="small"
:row-class-name="(r) => (r.isMultiUser() ? '' : 'hide-expand-icon')" @change="onTableChange">
<!-- Per-inbound client list, expanded by clicking the row's
default expand chevron. Hidden via row-class-name for
non-multi-user inbounds (matches legacy behavior). -->
<template #expandedRowRender="{ record }">
<ClientRowTable v-if="record.isMultiUser()" :db-inbound="record" :is-mobile="isMobile"
:traffic-diff="trafficDiff" :expire-diff="expireDiff" :online-clients="onlineClients"
:last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme" :page-size="pageSize"
:total-client-count="clientCount[record.id]?.clients || 0" :stats-version="statsVersion"
@edit-client="(p) => emit('edit-client', p)" @qrcode-client="(p) => emit('qrcode-client', p)"
@info-client="(p) => emit('info-client', p)" @reset-traffic-client="(p) => emit('reset-traffic-client', p)"
@delete-client="(p) => emit('delete-client', p)" @delete-clients="(p) => emit('delete-clients', p)"
@toggle-enable-client="(p) => emit('toggle-enable-client', p)" />
</template>
@change="onTableChange">
<template #bodyCell="{ column, record }">
<!-- ============== Action dropdown ============== -->
<template v-if="column.key === 'action'">
@ -579,15 +517,6 @@ function showQrCodeMenu(dbInbound) {
<QrcodeOutlined /> {{ t('qrCode') }}
</a-menu-item>
<template v-if="record.isMultiUser()">
<a-menu-item key="addClient">
<UserAddOutlined /> {{ t('pages.clients.add') }}
</a-menu-item>
<a-menu-item key="addBulkClient">
<UsergroupAddOutlined /> {{ t('pages.clients.bulk') }}
</a-menu-item>
<a-menu-item key="copyClients">
<CopyOutlined /> {{ t('pages.clients.copyFromInbound') }}
</a-menu-item>
<a-menu-item key="resetClients">
<FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
</a-menu-item>
@ -789,23 +718,6 @@ function showQrCodeMenu(dbInbound) {
color: #ff4d4f;
}
/* Hide the expand chevron on rows whose inbound has no client list
* (HTTP/Mixed/Tunnel/WireGuard single-config). */
:deep(.hide-expand-icon .ant-table-row-expand-icon) {
visibility: hidden;
}
/* Push the expand chevron away from the table's left edge so it has
* a little breathing room instead of being flush against the corner. */
:deep(.ant-table-tbody .ant-table-cell-with-append) {
padding-left: 12px;
}
:deep(.ant-table-row-expand-icon) {
margin-inline-end: 10px;
margin-inline-start: 4px;
}
/* Round the table's outer corners AD-Vue gives .ant-table the radius
* token, but the inner header strip and footer touch the edges, so clip
* them here. */
@ -890,17 +802,6 @@ function showQrCodeMenu(dbInbound) {
flex-shrink: 0;
}
.card-expand {
font-size: 12px;
opacity: 0.6;
transition: transform 150ms ease;
flex-shrink: 0;
}
.card-expand.is-expanded {
transform: rotate(90deg);
}
.card-stats {
display: flex;
flex-direction: column;
@ -927,11 +828,6 @@ function showQrCodeMenu(dbInbound) {
margin: 0;
}
.card-clients {
margin-top: 4px;
padding-top: 8px;
border-top: 1px solid rgba(128, 128, 128, 0.15);
}
.card-empty {
text-align: center;

View file

@ -18,9 +18,6 @@ import CustomStatistic from '@/components/CustomStatistic.vue';
import { useNodeList } from '@/composables/useNodeList.js';
import InboundList from './InboundList.vue';
import InboundFormModal from './InboundFormModal.vue';
import ClientFormModal from './ClientFormModal.vue';
import ClientBulkModal from './ClientBulkModal.vue';
import CopyClientsModal from './CopyClientsModal.vue';
import InboundInfoModal from './InboundInfoModal.vue';
import QrCodeModal from './QrCodeModal.vue';
import TextModal from '@/components/TextModal.vue';
@ -81,17 +78,6 @@ const formOpen = ref(false);
const formMode = ref('add');
const formDbInbound = ref(null);
// === Client modal (single + bulk) =====================================
const clientOpen = ref(false);
const clientMode = ref('add');
const clientDbInbound = ref(null);
const clientIndex = ref(null);
const bulkOpen = ref(false);
const bulkDbInbound = ref(null);
const copyOpen = ref(false);
const copyDbInbound = ref(null);
// === Info / QR-code modals ===========================================
const infoOpen = ref(false);
const infoDbInbound = ref(null);
@ -283,73 +269,6 @@ function findClientIndex(dbInbound, client) {
return idx >= 0 ? idx : 0;
}
function getClientId(protocol, client) {
switch (protocol) {
case 'trojan': return client.password;
case 'shadowsocks': return client.email;
case 'hysteria': return client.auth;
default: return client.id;
}
}
// === Per-client handlers (called from the expand-row table) =========
function onEditClient({ dbInbound, client }) {
clientMode.value = 'edit';
clientDbInbound.value = dbInbound;
clientIndex.value = findClientIndex(dbInbound, client);
clientOpen.value = true;
}
function onQrcodeClient({ dbInbound, client }) {
qrDbInbound.value = checkFallback(dbInbound);
qrClient.value = client || null;
qrOpen.value = true;
}
function onInfoClient({ dbInbound, client }) {
infoDbInbound.value = checkFallback(dbInbound);
infoClientIndex.value = findClientIndex(dbInbound, client);
infoOpen.value = true;
}
async function onResetTrafficClient({ dbInbound, client }) {
const msg = await HttpUtil.post(
`/panel/api/inbounds/${dbInbound.id}/resetClientTraffic/${client.email}`,
);
if (msg?.success) await refresh();
}
async function onDeleteClient({ dbInbound, client }) {
const clientId = getClientId(dbInbound.protocol, client);
const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/delClient/${clientId}`);
if (msg?.success) await refresh();
}
async function onDeleteClients({ dbInbound, clients }) {
for (const client of clients) {
const clientId = getClientId(dbInbound.protocol, client);
await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/delClient/${clientId}`);
}
await refresh();
}
async function onToggleEnableClient({ dbInbound, client, next }) {
// Mirror legacy: clone the parsed inbound, flip enable on the matching
// client, and post the whole client back through updateClient. This
// keeps the wire shape identical to the modal save path.
const inbound = dbInbound.toInbound();
const clients = inbound?.clients || [];
const idx = findClientIndex(dbInbound, client);
if (idx < 0 || !clients[idx]) return;
clients[idx].enable = next;
const clientId = getClientId(dbInbound.protocol, clients[idx]);
const msg = await HttpUtil.post(`/panel/api/inbounds/updateClient/${clientId}`, {
id: dbInbound.id,
settings: `{"clients": [${clients[idx].toString()}]}`,
});
if (msg?.success) await refresh();
}
function onAddInbound() {
formMode.value = 'add';
formDbInbound.value = null;
@ -362,18 +281,6 @@ function openEdit(dbInbound) {
formOpen.value = true;
}
function openAddClient(dbInbound) {
clientMode.value = 'add';
clientDbInbound.value = dbInbound;
clientIndex.value = null;
clientOpen.value = true;
}
function openAddBulkClient(dbInbound) {
bulkDbInbound.value = dbInbound;
bulkOpen.value = true;
}
// Per-row destructive actions go through Modal.confirm (matches legacy).
function confirmDelete(dbInbound) {
Modal.confirm({
@ -492,12 +399,6 @@ function onRowAction({ key, dbInbound }) {
case 'edit':
openEdit(dbInbound);
break;
case 'addClient':
openAddClient(dbInbound);
break;
case 'addBulkClient':
openAddBulkClient(dbInbound);
break;
case 'showInfo':
infoDbInbound.value = checkFallback(dbInbound);
infoClientIndex.value = findClientIndex(dbInbound, null);
@ -517,10 +418,6 @@ function onRowAction({ key, dbInbound }) {
case 'clipboard':
exportInboundClipboard(dbInbound);
break;
case 'copyClients':
copyDbInbound.value = dbInbound;
copyOpen.value = true;
break;
case 'delete':
confirmDelete(dbInbound);
break;
@ -642,10 +539,7 @@ function onRowAction({ key, dbInbound }) {
:sub-enable="subSettings.enable" :nodes-by-id="nodesById" :has-active-node="hasActiveNode"
:stats-version="statsVersion"
@refresh="refresh"
@add-inbound="onAddInbound" @general-action="onGeneralAction" @row-action="onRowAction"
@edit-client="onEditClient" @qrcode-client="onQrcodeClient" @info-client="onInfoClient"
@reset-traffic-client="onResetTrafficClient" @delete-client="onDeleteClient"
@delete-clients="onDeleteClients" @toggle-enable-client="onToggleEnableClient" />
@add-inbound="onAddInbound" @general-action="onGeneralAction" @row-action="onRowAction" />
</a-col>
</a-row>
</a-spin>
@ -654,13 +548,6 @@ function onRowAction({ key, dbInbound }) {
<InboundFormModal v-model:open="formOpen" :mode="formMode" :db-inbound="formDbInbound"
:db-inbounds="dbInbounds" @saved="refresh" />
<ClientFormModal v-model:open="clientOpen" :mode="clientMode" :db-inbound="clientDbInbound"
:client-index="clientIndex" :sub-enable="subSettings.enable" :tg-bot-enable="tgBotEnable"
:ip-limit-enable="ipLimitEnable" :traffic-diff="trafficDiff" @saved="refresh" />
<ClientBulkModal v-model:open="bulkOpen" :db-inbound="bulkDbInbound" :sub-enable="subSettings.enable"
:tg-bot-enable="tgBotEnable" :ip-limit-enable="ipLimitEnable" @saved="refresh" />
<CopyClientsModal v-model:open="copyOpen" :db-inbound="copyDbInbound" :db-inbounds="dbInbounds"
@saved="refresh" />
<InboundInfoModal v-model:open="infoOpen" :db-inbound="infoDbInbound" :client-index="infoClientIndex"
:remark-model="remarkModel" :expire-diff="expireDiff" :traffic-diff="trafficDiff"
:ip-limit-enable="ipLimitEnable" :tg-bot-enable="tgBotEnable" :sub-settings="subSettings"