feat(frontend): Phase 5f-vi — per-inbound client expand-row table

Each multi-user inbound row in the list now expands to show its
client roster, mirroring the legacy aClientTable component.

- ClientRowTable.vue: inner a-table with full desktop column set
  (action icons / enable / online / client-with-status-dot / traffic
  with progress bar / all-time / expiry with reset cycle) and a
  collapsed mobile variant (single dropdown menu + popover info).
  Self-contained: stats are looked up via a per-inbound email->stats
  Map; per-client confirms (reset/delete) live on the row.
- The component emits typed events (edit/qrcode/info/reset-traffic/
  delete/toggle-enable) — InboundsPage routes them back to the
  existing client and info modals (with `findClientIndex` so the
  modal opens focused on the right client).
- InboundList.vue: hooks ClientRowTable into the a-table's
  expandedRowRender slot; row-class-name `hide-expand-icon` and a
  scoped CSS rule hide the chevron for non-multi-user inbounds
  (HTTP/Mixed/Tunnel/WireGuard/SS-single) so they keep looking flat.
- toggle-enable-client routes through updateClient with the same
  `{id, settings: '{"clients": [...]}'}` shape as the other modals,
  so backend parsing stays single-pathed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MHSanaei 2026-05-08 14:00:39 +02:00
parent bb74e425fe
commit 7cab70c782
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
3 changed files with 625 additions and 7 deletions

View file

@ -0,0 +1,501 @@
<script setup>
import { computed } from 'vue';
import {
EditOutlined,
InfoCircleOutlined,
QrcodeOutlined,
RetweetOutlined,
DeleteOutlined,
EllipsisOutlined,
} from '@ant-design/icons-vue';
import { Modal } from 'ant-design-vue';
import { SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
// Per-inbound expand-row table. Rendered inside the inbound list's
// a-table#expandedRowRender slot for any inbound where
// `dbInbound.isMultiUser()` returns true. Mirrors the legacy
// component/aClientTable layout.
//
// The component itself does no API calls it emits typed events the
// parent routes back to the existing modals/handlers (edit, qr, info,
// reset traffic, delete, toggle-enable).
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 },
});
const emit = defineEmits([
'edit-client',
'qrcode-client',
'info-client',
'reset-traffic-client',
'delete-client',
'toggle-enable-client',
]);
// Surface the parsed Inbound so we can read its clients array
// directly. legacy used dbInbound.toInbound().clients via a
// `getInboundClients` helper; the parsed cache is invalidated on
// every refresh by useInbounds.setInbounds.
const inbound = computed(() => props.dbInbound.toInbound());
const clients = computed(() => inbound.value?.clients || []);
// === Per-client stats lookup =======================================
// Mirrors the legacy lazy-built email->stats Map cached on the
// dbInbound; recomputed when the underlying clientStats array is
// replaced by a refresh.
const statsMap = computed(() => {
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 getAllTime(email) {
const s = statsFor(email);
if (!s) return 0;
// allTime is the cumulative-historical counter; never let it dip
// below up+down (manual edits / partial migrations can push it under).
const current = (s.up || 0) + (s.down || 0);
return s.allTime > current ? s.allTime : current;
}
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);
}
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) {
if (!email) return '#7a316f';
const s = statsFor(email);
if (!s) return '#7a316f';
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 '#cf3c3c';
if (a === 'orange' || b === 'orange') return '#f37b24';
if (a === 'green' || b === 'green') return '#008771';
return '#7a316f';
}
// === Helpers ========================================================
const isRemovable = computed(() => clients.value.length > 1);
function totalGbDisplay(client) {
if (!client.totalGB || client.totalGB <= 0) return '∞';
// The model class exposes ._totalGB as bytes->GB for the form, but
// the table shows a coarser rounding. Match legacy: tail with 'GB'.
return `${Math.round((client.totalGB / 1073741824) * 100) / 100} GB`;
}
function statusBadgeColor(client) {
if (!client.enable) return props.isDarkTheme ? '#2c3950' : '#bcbcbc';
return statsExpColor(client.email);
}
// === Action confirms (mounted on the row, not a modal) ==============
function confirmReset(client) {
Modal.confirm({
title: `Reset traffic for ${client.email}?`,
content: 'Resets up/down counters to 0 for this client.',
okText: 'Reset',
cancelText: 'Cancel',
onOk: () => emit('reset-traffic-client', { dbInbound: props.dbInbound, client }),
});
}
function confirmDelete(client) {
Modal.confirm({
title: `Delete client ${client.email}?`,
content: 'This cannot be undone.',
okText: 'Delete',
okType: 'danger',
cancelText: 'Cancel',
onOk: () => emit('delete-client', { dbInbound: props.dbInbound, client }),
});
}
// === Columns ========================================================
// Two layouts: desktop has icon-row actions across; mobile collapses
// the per-row actions into a single dropdown + an info popover.
const desktopColumns = [
{ title: 'Action', key: 'actions', width: 140 },
{ title: 'Enable', key: 'enable', width: 60 },
{ title: 'Online', key: 'online', width: 80 },
{ title: 'Client', key: 'client', width: 160 },
{ title: 'Traffic', key: 'traffic', align: 'center', width: 200 },
{ title: 'All-time', key: 'allTime', align: 'center', width: 110 },
{ title: 'Expiry', key: 'expiryTime', align: 'center', width: 180 },
];
const mobileColumns = [
{ title: 'Action', key: 'actionMenu', align: 'center', width: 10 },
{ title: 'Client', key: 'client', align: 'left', width: 90 },
{ title: 'Info', key: 'info', align: 'center', width: 10 },
];
const columns = computed(() => (props.isMobile ? mobileColumns : desktopColumns));
</script>
<template>
<a-table
:columns="columns"
:data-source="clients"
:row-key="(c) => c.email || c.id || c.password"
:pagination="false"
:scroll="isMobile ? {} : { x: 'max-content' }"
size="small"
class="client-row-table"
>
<template #bodyCell="{ column, record }">
<!-- ============== Desktop action icons ============== -->
<template v-if="column.key === 'actions'">
<a-space :size="6">
<a-tooltip v-if="dbInbound.hasLink()" title="QR code">
<QrcodeOutlined
class="row-icon"
@click="emit('qrcode-client', { dbInbound, client: record })"
/>
</a-tooltip>
<a-tooltip title="Edit">
<EditOutlined
class="row-icon"
@click="emit('edit-client', { dbInbound, client: record })"
/>
</a-tooltip>
<a-tooltip title="Info">
<InfoCircleOutlined
class="row-icon"
@click="emit('info-client', { dbInbound, client: record })"
/>
</a-tooltip>
<a-tooltip v-if="record.email" title="Reset traffic">
<RetweetOutlined class="row-icon" @click="confirmReset(record)" />
</a-tooltip>
<a-tooltip v-if="isRemovable" title="Delete">
<DeleteOutlined class="row-icon danger" @click="confirmDelete(record)" />
</a-tooltip>
</a-space>
</template>
<!-- ============== Enable switch ============== -->
<template v-else-if="column.key === 'enable'">
<a-switch
:checked="record.enable"
@change="(next) => emit('toggle-enable-client', { dbInbound, client: record, next })"
/>
</template>
<!-- ============== Online tag ============== -->
<template v-else-if="column.key === 'online'">
<a-popover>
<template #content>Last online: {{ lastOnlineLabel(record.email) }}</template>
<a-tag v-if="record.enable && isClientOnline(record.email)" color="green">online</a-tag>
<a-tag v-else>offline</a-tag>
</a-popover>
</template>
<!-- ============== Client identity (status dot + email + comment) ============== -->
<template v-else-if="column.key === 'client'">
<a-space :size="2" class="client-id-cell" :style="{ flexWrap: 'nowrap' }">
<a-tooltip>
<template #title>
<template v-if="isClientDepleted(record.email)">depleted</template>
<template v-else-if="!record.enable">disabled</template>
<template v-else-if="isClientOnline(record.email)">online</template>
<template v-else>offline</template>
</template>
<a-badge :color="statusBadgeColor(record)" />
</a-tooltip>
<a-space direction="vertical" :size="2" class="client-id-stack">
<a-tooltip :title="record.email">
<span class="client-email">{{ record.email }}</span>
</a-tooltip>
<span v-if="record.comment && record.comment.trim()" class="client-comment">
{{ record.comment.length > 50 ? record.comment.substring(0, 47) + '…' : record.comment }}
</span>
</a-space>
</a-space>
</template>
<!-- ============== Traffic with progress bar ============== -->
<template v-else-if="column.key === 'traffic'">
<a-popover>
<template v-if="record.email" #content>
<table cellpadding="2">
<tbody>
<tr>
<td> {{ SizeFormatter.sizeFormat(getUp(record.email)) }}</td>
<td> {{ SizeFormatter.sizeFormat(getDown(record.email)) }}</td>
</tr>
<tr v-if="record.totalGB > 0">
<td>Remaining</td>
<td>{{ SizeFormatter.sizeFormat(getRem(record.email)) }}</td>
</tr>
</tbody>
</table>
</template>
<div class="traffic-cell">
<div class="traffic-text">{{ SizeFormatter.sizeFormat(getSum(record.email)) }}</div>
<div class="traffic-bar" v-if="!record.enable">
<a-progress
:stroke-color="isDarkTheme ? 'rgb(72,84,105)' : '#bcbcbc'"
:show-info="false"
:percent="statsProgress(record.email)"
/>
</div>
<div class="traffic-bar" v-else-if="record.totalGB > 0">
<a-progress
:stroke-color="clientStatsColor(record.email)"
:show-info="false"
:status="isClientDepleted(record.email) ? 'exception' : ''"
:percent="statsProgress(record.email)"
/>
</div>
<div class="traffic-bar infinite" v-else>
<a-progress :show-info="false" :percent="100" />
</div>
<div class="traffic-text">{{ totalGbDisplay(record) }}</div>
</div>
</a-popover>
</template>
<!-- ============== All-time ============== -->
<template v-else-if="column.key === 'allTime'">
<a-tag>{{ SizeFormatter.sizeFormat(getAllTime(record.email)) }}</a-tag>
</template>
<!-- ============== Expiry ============== -->
<template v-else-if="column.key === 'expiryTime'">
<template v-if="record.expiryTime !== 0 && record.reset > 0">
<a-popover>
<template #content>
<span v-if="record.expiryTime < 0">Delayed start</span>
<span v-else>{{ IntlUtil.formatDate(record.expiryTime) }}</span>
</template>
<div class="traffic-cell">
<div class="traffic-text">{{ IntlUtil.formatRelativeTime(record.expiryTime) }}</div>
<div class="traffic-bar infinite">
<a-progress
:show-info="false"
:status="isClientDepleted(record.email) ? 'exception' : ''"
:percent="expireProgress(record.expiryTime, record.reset)"
/>
</div>
<div class="traffic-text">{{ record.reset }}d</div>
</div>
</a-popover>
</template>
<template v-else>
<a-popover v-if="record.expiryTime !== 0">
<template #content>
<span v-if="record.expiryTime < 0">Delayed start</span>
<span v-else>{{ IntlUtil.formatDate(record.expiryTime) }}</span>
</template>
<a-tag
:style="{ minWidth: '50px', border: 'none' }"
:color="ColorUtils.userExpiryColor(expireDiff, record, isDarkTheme)"
>
{{ IntlUtil.formatRelativeTime(record.expiryTime) }}
</a-tag>
</a-popover>
<a-tag
v-else
:color="ColorUtils.userExpiryColor(expireDiff, record, isDarkTheme)"
:style="{ border: 'none' }"
>
</a-tag>
</template>
</template>
<!-- ============== Mobile-only action menu ============== -->
<template v-else-if="column.key === 'actionMenu'">
<a-dropdown :trigger="['click']">
<EllipsisOutlined class="row-icon" @click.prevent />
<template #overlay>
<a-menu>
<a-menu-item
v-if="dbInbound.hasLink()"
@click="emit('qrcode-client', { dbInbound, client: record })"
><QrcodeOutlined /> QR code</a-menu-item>
<a-menu-item @click="emit('edit-client', { dbInbound, client: record })">
<EditOutlined /> Edit
</a-menu-item>
<a-menu-item @click="emit('info-client', { dbInbound, client: record })">
<InfoCircleOutlined /> Info
</a-menu-item>
<a-menu-item v-if="record.email" @click="confirmReset(record)">
<RetweetOutlined /> Reset traffic
</a-menu-item>
<a-menu-item v-if="isRemovable" @click="confirmDelete(record)">
<DeleteOutlined /> <span class="danger">Delete</span>
</a-menu-item>
<a-menu-item>
<a-switch
size="small"
:checked="record.enable"
@change="(next) => emit('toggle-enable-client', { dbInbound, client: record, next })"
/>
Enable
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
<!-- ============== Mobile info popover ============== -->
<template v-else-if="column.key === 'info'">
<a-popover :placement="isMobile ? 'bottomLeft' : 'bottomRight'" trigger="click">
<template #content>
<table cellpadding="2">
<tbody>
<tr>
<td colspan="2" class="text-center">Traffic</td>
</tr>
<tr>
<td class="num-cell">
{{ SizeFormatter.sizeFormat(getSum(record.email)) }}
</td>
<td class="num-cell">{{ totalGbDisplay(record) }}</td>
</tr>
<tr>
<td colspan="2" class="text-center">
<a-divider style="margin: 0" />
Expiry
</td>
</tr>
<tr>
<td colspan="2" class="text-center">
<a-tag v-if="record.expiryTime > 0">
{{ IntlUtil.formatRelativeTime(record.expiryTime) }}
</a-tag>
<a-tag v-else-if="record.expiryTime < 0" color="green">
{{ -record.expiryTime / 86400000 }} d (delayed)
</a-tag>
<a-tag v-else color="purple"></a-tag>
</td>
</tr>
</tbody>
</table>
</template>
<a-button shape="round" size="small">
<InfoCircleOutlined />
</a-button>
</a-popover>
</template>
</template>
</a-table>
</template>
<style scoped>
.client-row-table {
margin: -10px 22px -21px;
}
:deep(.client-row-table .ant-table-tbody > tr > td) {
padding-top: 6px;
padding-bottom: 6px;
}
.row-icon {
font-size: 18px;
cursor: pointer;
padding: 0 4px;
}
.row-icon.danger,
.danger {
color: #ff4d4f;
}
.client-id-cell {
display: inline-flex;
align-items: center;
min-width: 0;
}
.client-id-stack {
min-width: 0;
overflow: hidden;
}
.client-email {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
display: inline-block;
}
.client-comment {
font-size: 11px;
opacity: 0.7;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
display: inline-block;
}
.traffic-cell {
display: grid;
grid-template-columns: minmax(60px, auto) 1fr minmax(50px, auto);
align-items: center;
gap: 6px;
min-width: 180px;
}
.traffic-text {
font-size: 12px;
white-space: nowrap;
}
.traffic-bar {
min-width: 40px;
}
.traffic-bar.infinite :deep(.ant-progress-inner) {
background: rgba(122, 49, 111, 0.15);
}
.text-center { text-align: center; }
.num-cell { text-align: right; font-size: 12px; padding: 2px 6px; }
</style>

View file

@ -27,16 +27,19 @@ import {
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 ClientRowTable from './ClientRowTable.vue';
const props = defineProps({ const props = defineProps({
dbInbounds: { type: Array, required: true }, dbInbounds: { type: Array, required: true },
clientCount: { type: Object, required: true }, clientCount: { type: Object, required: true },
onlineClients: { type: Array, required: true }, onlineClients: { type: Array, required: true },
lastOnlineMap: { type: Object, default: () => ({}) },
refreshing: { type: Boolean, default: false }, refreshing: { type: Boolean, default: false },
expireDiff: { type: Number, default: 0 }, expireDiff: { type: Number, default: 0 },
trafficDiff: { type: Number, default: 0 }, trafficDiff: { type: Number, default: 0 },
pageSize: { type: Number, default: 0 }, pageSize: { type: Number, default: 0 },
isMobile: { type: Boolean, default: false }, isMobile: { type: Boolean, default: false },
isDarkTheme: { type: Boolean, default: false },
subEnable: { type: Boolean, default: false }, subEnable: { type: Boolean, default: false },
}); });
@ -45,6 +48,13 @@ 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',
'toggle-enable-client',
]); ]);
// ============ Toolbar / search & filter ============================= // ============ Toolbar / search & filter =============================
@ -302,7 +312,30 @@ function showQrCodeMenu(dbInbound) {
:scroll="isMobile ? {} : { x: 1000 }" :scroll="isMobile ? {} : { x: 1000 }"
:style="{ marginTop: '10px' }" :style="{ marginTop: '10px' }"
size="small" size="small"
:row-class-name="(r) => (r.isMultiUser() ? '' : 'hide-expand-icon')"
> >
<!-- 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"
@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)"
@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'">
@ -517,4 +550,10 @@ function showQrCodeMenu(dbInbound) {
.danger-item { .danger-item {
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;
}
</style> </style>

View file

@ -109,12 +109,82 @@ function checkFallback(dbInbound) {
return dbInbound; return dbInbound;
} }
function findClientIndex(dbInbound) { function findClientIndex(dbInbound, client) {
// For now we always show client 0 multi-client navigation lives if (!client) return 0;
// in the per-inbound expand-row table (5f-vi). A future commit will const inbound = dbInbound.toInbound();
// route client.email through this helper. const clients = inbound?.clients || [];
void dbInbound; const idx = clients.findIndex((c) => {
return 0; if (!c) return false;
switch (dbInbound.protocol) {
case 'trojan':
case 'shadowsocks':
return c.password === client.password && c.email === client.email;
default:
return c.id === client.id && c.email === client.email;
}
});
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 }) {
// Reuse the inbound info modal focused on the chosen client that's
// where per-client share links and the per-link QRs live.
infoDbInbound.value = checkFallback(dbInbound);
infoClientIndex.value = findClientIndex(dbInbound, client);
infoOpen.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 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() {
@ -258,7 +328,7 @@ function onRowAction({ key, dbInbound }) {
break; break;
case 'showInfo': case 'showInfo':
infoDbInbound.value = checkFallback(dbInbound); infoDbInbound.value = checkFallback(dbInbound);
infoClientIndex.value = findClientIndex(dbInbound); infoClientIndex.value = findClientIndex(dbInbound, null);
infoOpen.value = true; infoOpen.value = true;
break; break;
case 'qrcode': case 'qrcode':
@ -364,6 +434,8 @@ function onRowAction({ key, dbInbound }) {
:db-inbounds="dbInbounds" :db-inbounds="dbInbounds"
:client-count="clientCount" :client-count="clientCount"
:online-clients="onlineClients" :online-clients="onlineClients"
:last-online-map="lastOnlineMap"
:is-dark-theme="themeState.isDark"
:refreshing="refreshing" :refreshing="refreshing"
:expire-diff="expireDiff" :expire-diff="expireDiff"
:traffic-diff="trafficDiff" :traffic-diff="trafficDiff"
@ -374,6 +446,12 @@ function onRowAction({ key, dbInbound }) {
@add-inbound="onAddInbound" @add-inbound="onAddInbound"
@general-action="onGeneralAction" @general-action="onGeneralAction"
@row-action="onRowAction" @row-action="onRowAction"
@edit-client="onEditClient"
@qrcode-client="onQrcodeClient"
@info-client="onInfoClient"
@reset-traffic-client="onResetTrafficClient"
@delete-client="onDeleteClient"
@toggle-enable-client="onToggleEnableClient"
/> />
</a-col> </a-col>
</a-row> </a-row>