mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-13 09:36:05 +00:00
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:
parent
bb74e425fe
commit
7cab70c782
3 changed files with 625 additions and 7 deletions
501
frontend/src/pages/inbounds/ClientRowTable.vue
Normal file
501
frontend/src/pages/inbounds/ClientRowTable.vue
Normal 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>
|
||||
|
|
@ -27,16 +27,19 @@ import {
|
|||
import { HttpUtil, ObjectUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
|
||||
import { DBInbound } from '@/models/dbinbound.js';
|
||||
import { Inbound } from '@/models/inbound.js';
|
||||
import ClientRowTable from './ClientRowTable.vue';
|
||||
|
||||
const props = defineProps({
|
||||
dbInbounds: { type: Array, required: true },
|
||||
clientCount: { type: Object, required: true },
|
||||
onlineClients: { type: Array, required: true },
|
||||
lastOnlineMap: { type: Object, default: () => ({}) },
|
||||
refreshing: { type: Boolean, default: false },
|
||||
expireDiff: { type: Number, default: 0 },
|
||||
trafficDiff: { type: Number, default: 0 },
|
||||
pageSize: { type: Number, default: 0 },
|
||||
isMobile: { type: Boolean, default: false },
|
||||
isDarkTheme: { type: Boolean, default: false },
|
||||
subEnable: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
|
|
@ -45,6 +48,13 @@ 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',
|
||||
'toggle-enable-client',
|
||||
]);
|
||||
|
||||
// ============ Toolbar / search & filter =============================
|
||||
|
|
@ -302,7 +312,30 @@ function showQrCodeMenu(dbInbound) {
|
|||
:scroll="isMobile ? {} : { x: 1000 }"
|
||||
:style="{ marginTop: '10px' }"
|
||||
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 }">
|
||||
<!-- ============== Action dropdown ============== -->
|
||||
<template v-if="column.key === 'action'">
|
||||
|
|
@ -517,4 +550,10 @@ function showQrCodeMenu(dbInbound) {
|
|||
.danger-item {
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -109,12 +109,82 @@ function checkFallback(dbInbound) {
|
|||
return dbInbound;
|
||||
}
|
||||
|
||||
function findClientIndex(dbInbound) {
|
||||
// For now we always show client 0 — multi-client navigation lives
|
||||
// in the per-inbound expand-row table (5f-vi). A future commit will
|
||||
// route client.email through this helper.
|
||||
void dbInbound;
|
||||
return 0;
|
||||
function findClientIndex(dbInbound, client) {
|
||||
if (!client) return 0;
|
||||
const inbound = dbInbound.toInbound();
|
||||
const clients = inbound?.clients || [];
|
||||
const idx = clients.findIndex((c) => {
|
||||
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() {
|
||||
|
|
@ -258,7 +328,7 @@ function onRowAction({ key, dbInbound }) {
|
|||
break;
|
||||
case 'showInfo':
|
||||
infoDbInbound.value = checkFallback(dbInbound);
|
||||
infoClientIndex.value = findClientIndex(dbInbound);
|
||||
infoClientIndex.value = findClientIndex(dbInbound, null);
|
||||
infoOpen.value = true;
|
||||
break;
|
||||
case 'qrcode':
|
||||
|
|
@ -364,6 +434,8 @@ function onRowAction({ key, dbInbound }) {
|
|||
:db-inbounds="dbInbounds"
|
||||
:client-count="clientCount"
|
||||
:online-clients="onlineClients"
|
||||
:last-online-map="lastOnlineMap"
|
||||
:is-dark-theme="themeState.isDark"
|
||||
:refreshing="refreshing"
|
||||
:expire-diff="expireDiff"
|
||||
:traffic-diff="trafficDiff"
|
||||
|
|
@ -374,6 +446,12 @@ function onRowAction({ key, dbInbound }) {
|
|||
@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"
|
||||
@toggle-enable-client="onToggleEnableClient"
|
||||
/>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
|
|
|||
Loading…
Reference in a new issue