mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-13 09:36:05 +00:00
feat(inbounds): mobile card layout for inbounds and clients
Replace the cramped <a-table> on <768px with a stacked card list for both inbounds and the per-client expanded rows. Each card surfaces protocol, port, node, traffic, all-time traffic, client count and expiry inline as labeled rows instead of hiding them behind popovers, fixes the 0px gutter that made cards visually merge, and softens the in-quota green from #52c41a to #389e0a (Ant green-7) so traffic tags are no longer blinding on dark themes.
This commit is contained in:
parent
b776b33497
commit
5ac88271af
4 changed files with 532 additions and 330 deletions
|
|
@ -166,22 +166,20 @@ function rowKey(client) {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="client-list" :class="{ 'is-mobile': isMobile, 'is-dark': isDarkTheme }">
|
<div class="client-list" :class="{ 'is-mobile': isMobile, 'is-dark': isDarkTheme }">
|
||||||
<!-- ============== Header (desktop only) ============== -->
|
<!-- ====================== Desktop: grid table ===================== -->
|
||||||
<div v-if="!isMobile" class="client-row client-list-header">
|
<template v-if="!isMobile">
|
||||||
<div class="cell cell-actions">{{ t('pages.settings.actions') }}</div>
|
<div class="client-row client-list-header">
|
||||||
<div class="cell cell-enable">{{ t('enable') }}</div>
|
<div class="cell cell-actions">{{ t('pages.settings.actions') }}</div>
|
||||||
<div class="cell cell-online">{{ t('online') }}</div>
|
<div class="cell cell-enable">{{ t('enable') }}</div>
|
||||||
<div class="cell cell-client">{{ t('pages.inbounds.client') }}</div>
|
<div class="cell cell-online">{{ t('online') }}</div>
|
||||||
<div class="cell cell-traffic">{{ t('pages.inbounds.traffic') }}</div>
|
<div class="cell cell-client">{{ t('pages.inbounds.client') }}</div>
|
||||||
<div class="cell cell-alltime">{{ t('pages.inbounds.allTimeTraffic') }}</div>
|
<div class="cell cell-traffic">{{ t('pages.inbounds.traffic') }}</div>
|
||||||
<div class="cell cell-expiry">{{ t('pages.inbounds.expireDate') }}</div>
|
<div class="cell cell-alltime">{{ t('pages.inbounds.allTimeTraffic') }}</div>
|
||||||
</div>
|
<div class="cell cell-expiry">{{ t('pages.inbounds.expireDate') }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ============== Body rows ============== -->
|
<div v-for="client in clients" :key="rowKey(client)" class="client-row">
|
||||||
<div v-for="client in clients" :key="rowKey(client)" class="client-row">
|
<div class="cell cell-actions">
|
||||||
<!-- Desktop: action icon row | Mobile: dropdown menu -->
|
|
||||||
<div class="cell cell-actions">
|
|
||||||
<template v-if="!isMobile">
|
|
||||||
<a-tooltip v-if="dbInbound.hasLink()" :title="t('qrCode')">
|
<a-tooltip v-if="dbInbound.hasLink()" :title="t('qrCode')">
|
||||||
<QrcodeOutlined class="row-icon" @click="emit('qrcode-client', { dbInbound, client })" />
|
<QrcodeOutlined class="row-icon" @click="emit('qrcode-client', { dbInbound, client })" />
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
|
|
@ -197,181 +195,189 @@ function rowKey(client) {
|
||||||
<a-tooltip v-if="isRemovable" :title="t('delete')">
|
<a-tooltip v-if="isRemovable" :title="t('delete')">
|
||||||
<DeleteOutlined class="row-icon danger" @click="confirmDelete(client)" />
|
<DeleteOutlined class="row-icon danger" @click="confirmDelete(client)" />
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</template>
|
</div>
|
||||||
<a-dropdown v-else :trigger="['click']">
|
|
||||||
<EllipsisOutlined class="row-icon" @click.prevent />
|
<div class="cell cell-enable">
|
||||||
<template #overlay>
|
<a-switch :checked="client.enable" size="small"
|
||||||
<a-menu>
|
@change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
|
||||||
<a-menu-item v-if="dbInbound.hasLink()" @click="emit('qrcode-client', { dbInbound, client })">
|
</div>
|
||||||
<QrcodeOutlined /> {{ t('qrCode') }}
|
|
||||||
</a-menu-item>
|
<div class="cell cell-online">
|
||||||
<a-menu-item @click="emit('edit-client', { dbInbound, client })">
|
<a-popover>
|
||||||
<EditOutlined /> {{ t('edit') }}
|
<template #content>{{ t('lastOnline') }}: {{ lastOnlineLabel(client.email) }}</template>
|
||||||
</a-menu-item>
|
<a-tag v-if="client.enable && isClientOnline(client.email)" color="green">{{ t('online') }}</a-tag>
|
||||||
<a-menu-item @click="emit('info-client', { dbInbound, client })">
|
<a-tag v-else>{{ t('offline') }}</a-tag>
|
||||||
<InfoCircleOutlined /> {{ t('info') }}
|
</a-popover>
|
||||||
</a-menu-item>
|
</div>
|
||||||
<a-menu-item v-if="client.email" @click="confirmReset(client)">
|
|
||||||
<RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
|
<div class="cell cell-client">
|
||||||
</a-menu-item>
|
<a-tooltip>
|
||||||
<a-menu-item v-if="isRemovable" @click="confirmDelete(client)">
|
<template #title>
|
||||||
<DeleteOutlined /> <span class="danger">{{ t('delete') }}</span>
|
<template v-if="isClientDepleted(client.email)">{{ t('depleted') }}</template>
|
||||||
</a-menu-item>
|
<template v-else-if="!client.enable">{{ t('disabled') }}</template>
|
||||||
</a-menu>
|
<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-alltime">
|
||||||
|
<a-tag>{{ SizeFormatter.sizeFormat(getAllTime(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.client.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>
|
</template>
|
||||||
</a-dropdown>
|
<a-popover v-else-if="client.expiryTime !== 0">
|
||||||
|
<template #content>
|
||||||
|
<span v-if="client.expiryTime < 0">{{ t('pages.client.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>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Enable switch (hidden on mobile, lives in dropdown) -->
|
<!-- ====================== Mobile: card list ======================= -->
|
||||||
<div v-if="!isMobile" class="cell cell-enable">
|
<template v-else>
|
||||||
<a-switch :checked="client.enable" size="small"
|
<div v-for="client in clients" :key="rowKey(client)" class="client-card">
|
||||||
@change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
|
<div class="client-card-head">
|
||||||
</div>
|
<a-tooltip>
|
||||||
|
<template #title>
|
||||||
<!-- Online tag (desktop only) -->
|
<template v-if="isClientDepleted(client.email)">{{ t('depleted') }}</template>
|
||||||
<div v-if="!isMobile" class="cell cell-online">
|
<template v-else-if="!client.enable">{{ t('disabled') }}</template>
|
||||||
<a-popover>
|
<template v-else-if="isClientOnline(client.email)">{{ t('online') }}</template>
|
||||||
<template #content>{{ t('lastOnline') }}: {{ lastOnlineLabel(client.email) }}</template>
|
<template v-else>{{ t('offline') }}</template>
|
||||||
<a-tag v-if="client.enable && isClientOnline(client.email)" color="green">{{ t('online') }}</a-tag>
|
</template>
|
||||||
<a-tag v-else>{{ t('offline') }}</a-tag>
|
<a-badge :color="statusBadgeColor(client)" />
|
||||||
</a-popover>
|
</a-tooltip>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Client identity: status dot + email + comment -->
|
|
||||||
<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">
|
<a-tooltip :title="client.email">
|
||||||
<span class="client-email">{{ client.email }}</span>
|
<span class="client-email">{{ client.email }}</span>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
<span v-if="client.comment && client.comment.trim()" class="client-comment">
|
<div class="client-card-actions">
|
||||||
{{ client.comment.length > 50 ? client.comment.substring(0, 47) + '…' : client.comment }}
|
<a-switch :checked="client.enable" size="small"
|
||||||
</span>
|
@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>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Traffic with progress bar (desktop only) -->
|
<div v-if="client.comment && client.comment.trim()" class="client-comment-line">
|
||||||
<div v-if="!isMobile" class="cell cell-traffic">
|
{{ client.comment.length > 80 ? client.comment.substring(0, 77) + '…' : client.comment }}
|
||||||
<a-popover>
|
</div>
|
||||||
<template v-if="client.email" #content>
|
|
||||||
<table cellpadding="2">
|
<div class="client-card-foot">
|
||||||
<tbody>
|
<div class="stat-row">
|
||||||
<tr>
|
<span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
|
||||||
<td>↑ {{ SizeFormatter.sizeFormat(getUp(client.email)) }}</td>
|
<a-tag :color="clientStatsColor(client.email)">
|
||||||
<td>↓ {{ SizeFormatter.sizeFormat(getDown(client.email)) }}</td>
|
{{ SizeFormatter.sizeFormat(getSum(client.email)) }} /
|
||||||
</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)" />
|
<InfinityIcon v-if="isUnlimitedTotal(client)" />
|
||||||
<template v-else>{{ totalGbDisplay(client) }}</template>
|
<template v-else>{{ totalGbDisplay(client) }}</template>
|
||||||
</span>
|
</a-tag>
|
||||||
</div>
|
</div>
|
||||||
</a-popover>
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
|
||||||
|
<a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('online') }}</span>
|
||||||
|
<a-tag v-if="client.enable && isClientOnline(client.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="client.expiryTime > 0" :color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)">
|
||||||
|
{{ IntlUtil.formatRelativeTime(client.expiryTime) }}
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-else-if="client.expiryTime < 0" color="green">
|
||||||
|
{{ -client.expiryTime / 86400000 }}d ({{ t('pages.client.delayedStart') }})
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-else color="purple"><InfinityIcon /></a-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
<!-- All-time traffic (desktop only) -->
|
|
||||||
<div v-if="!isMobile" class="cell cell-alltime">
|
|
||||||
<a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Expiry (desktop only) -->
|
|
||||||
<div v-if="!isMobile" 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.client.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.client.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>
|
|
||||||
|
|
||||||
<!-- Mobile-only summary popover (collapses traffic + expiry) -->
|
|
||||||
<div v-if="isMobile" class="cell cell-mobile-info">
|
|
||||||
<a-popover placement="bottomLeft" trigger="click">
|
|
||||||
<template #content>
|
|
||||||
<table cellpadding="2">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td colspan="2" class="text-center">{{ t('pages.inbounds.traffic') }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="num-cell">{{ SizeFormatter.sizeFormat(getSum(client.email)) }}</td>
|
|
||||||
<td class="num-cell">
|
|
||||||
<InfinityIcon v-if="isUnlimitedTotal(client)" />
|
|
||||||
<template v-else>{{ totalGbDisplay(client) }}</template>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td colspan="2" class="text-center">
|
|
||||||
<a-divider style="margin: 0" />
|
|
||||||
{{ t('pages.inbounds.expireDate') }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td colspan="2" class="text-center">
|
|
||||||
<a-tag v-if="client.expiryTime > 0">
|
|
||||||
{{ IntlUtil.formatRelativeTime(client.expiryTime) }}
|
|
||||||
</a-tag>
|
|
||||||
<a-tag v-else-if="client.expiryTime < 0" color="green">
|
|
||||||
{{ -client.expiryTime / 86400000 }}d ({{ t('pages.client.delayedStart') }})
|
|
||||||
</a-tag>
|
|
||||||
<a-tag v-else color="purple">
|
|
||||||
<InfinityIcon />
|
|
||||||
</a-tag>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</template>
|
|
||||||
<a-button shape="round" size="small">
|
|
||||||
<InfoCircleOutlined />
|
|
||||||
</a-button>
|
|
||||||
</a-popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -419,12 +425,6 @@ function rowKey(client) {
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile collapses to a 3-column row: action menu, client info, info popover. */
|
|
||||||
.client-list.is-mobile .client-row {
|
|
||||||
grid-template-columns: 36px minmax(0, 1fr) 36px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell {
|
.cell {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
/* allow grid children to shrink instead of overflowing */
|
/* allow grid children to shrink instead of overflowing */
|
||||||
|
|
@ -433,8 +433,7 @@ function rowKey(client) {
|
||||||
.cell-actions,
|
.cell-actions,
|
||||||
.cell-enable,
|
.cell-enable,
|
||||||
.cell-online,
|
.cell-online,
|
||||||
.cell-alltime,
|
.cell-alltime {
|
||||||
.cell-mobile-info {
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -540,71 +539,93 @@ function rowKey(client) {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile popover content table */
|
/* Strip AD-Vue's default expanded-cell padding so the desktop grid
|
||||||
.text-center {
|
* sits flush against the inbound row's left/right edges. */
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.num-cell {
|
|
||||||
text-align: right;
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Strip AD-Vue's default expanded-cell padding so the grid sits
|
|
||||||
* flush against the inbound row's left/right edges. */
|
|
||||||
:deep(.ant-table-expanded-row > .ant-table-cell) {
|
:deep(.ant-table-expanded-row > .ant-table-cell) {
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Mobile polish ===============================================
|
/* ===== Mobile card list =========================================== */
|
||||||
* On phones the row collapses to [actions][client][info]. Give those
|
.client-list.is-mobile {
|
||||||
* cells room and bump the touch targets so the per-client action
|
display: flex;
|
||||||
* dropdown + info popover are easier to hit with a thumb. */
|
flex-direction: column;
|
||||||
@media (max-width: 768px) {
|
gap: 8px;
|
||||||
.client-list.is-mobile .client-row {
|
margin: 0;
|
||||||
grid-template-columns: 40px minmax(0, 1fr) 40px;
|
}
|
||||||
gap: 8px;
|
|
||||||
padding: 10px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-list.is-mobile .row-icon {
|
.client-card {
|
||||||
font-size: 20px;
|
border: 1px solid rgba(128, 128, 128, 0.18);
|
||||||
padding: 6px;
|
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-list.is-mobile .cell-mobile-info .ant-btn {
|
.client-card-head {
|
||||||
width: 32px;
|
display: flex;
|
||||||
height: 32px;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/* Make the email more readable; the comment can stay smaller. */
|
.client-comment-line {
|
||||||
.client-list.is-mobile .client-email {
|
font-size: 11px;
|
||||||
font-size: 14px;
|
opacity: 0.7;
|
||||||
font-weight: 500;
|
white-space: nowrap;
|
||||||
}
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.client-list.is-mobile .client-comment {
|
.client-card-foot {
|
||||||
font-size: 11px;
|
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 so depleted/online state is visible at a glance. */
|
/* Bigger status badge for thumb-readable state at a glance. */
|
||||||
.client-list.is-mobile .cell-client :deep(.ant-badge-status-dot) {
|
.client-card-head :deep(.ant-badge-status-dot) {
|
||||||
width: 9px;
|
width: 9px;
|
||||||
height: 9px;
|
height: 9px;
|
||||||
}
|
|
||||||
|
|
||||||
/* Row separators feel cleaner with a slight surface tint per row
|
|
||||||
* — easier to scan than a hairline border on dark backgrounds. */
|
|
||||||
.client-list.is-mobile .client-row:not(.client-list-header) {
|
|
||||||
background: rgba(128, 128, 128, 0.04);
|
|
||||||
border-radius: 8px;
|
|
||||||
margin: 4px 8px;
|
|
||||||
border: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-list.is-mobile .client-row:not(.client-list-header):last-child {
|
|
||||||
border: none !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
BlockOutlined,
|
BlockOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
InfoCircleOutlined,
|
InfoCircleOutlined,
|
||||||
|
RightOutlined,
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
|
|
||||||
import { HttpUtil, ObjectUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
|
import { HttpUtil, ObjectUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
|
||||||
|
|
@ -140,13 +141,20 @@ const desktopColumns = computed(() => {
|
||||||
);
|
);
|
||||||
return cols;
|
return cols;
|
||||||
});
|
});
|
||||||
const mobileColumns = computed(() => [
|
const columns = computed(() => desktopColumns.value);
|
||||||
{ title: 'ID', dataIndex: 'id', key: 'id', align: 'right', width: 10, responsive: ['s'] },
|
|
||||||
{ title: t('pages.inbounds.operate'), key: 'action', align: 'center', width: 25 },
|
// Mobile expansion state — replaces a-table's expandable() since the
|
||||||
{ title: t('pages.inbounds.remark'), dataIndex: 'remark', key: 'remark', align: 'left', width: 70 },
|
// mobile branch renders a hand-rolled card list rather than a table.
|
||||||
{ title: t('info'), key: 'info', align: 'center', width: 10 },
|
const expandedIds = ref(new Set());
|
||||||
]);
|
function toggleExpanded(id) {
|
||||||
const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopColumns.value));
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
// ============ Pagination ============================================
|
// ============ Pagination ============================================
|
||||||
function paginationFor(rows) {
|
function paginationFor(rows) {
|
||||||
|
|
@ -256,8 +264,155 @@ function showQrCodeMenu(dbInbound) {
|
||||||
</a-radio-group>
|
</a-radio-group>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a-table :columns="columns" :data-source="visibleInbounds" :row-key="(r) => r.id"
|
<!-- ====================== Mobile: card list ======================= -->
|
||||||
:pagination="paginationFor(visibleInbounds)" :scroll="isMobile ? {} : { x: 1000 }"
|
<div v-if="isMobile" class="inbound-cards">
|
||||||
|
<div v-if="visibleInbounds.length === 0" class="card-empty">—</div>
|
||||||
|
|
||||||
|
<div v-for="record in visibleInbounds" :key="record.id" class="inbound-card">
|
||||||
|
<!-- Header: chevron (multi-user only) + remark + 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) }" />
|
||||||
|
<span class="card-id">#{{ record.id }}</span>
|
||||||
|
<span class="tag-name">{{ record.remark }}</span>
|
||||||
|
<div class="card-actions" @click.stop>
|
||||||
|
<a-switch :checked="record.enable" size="small"
|
||||||
|
@change="(next) => onSwitchEnable(record, next)" />
|
||||||
|
<a-dropdown :trigger="['click']" placement="bottomRight">
|
||||||
|
<MoreOutlined class="row-action-trigger" @click.prevent />
|
||||||
|
<template #overlay>
|
||||||
|
<a-menu @click="(a) => emit('row-action', { key: a.key, dbInbound: record })">
|
||||||
|
<a-menu-item key="edit">
|
||||||
|
<EditOutlined /> {{ t('edit') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item v-if="showQrCodeMenu(record)" key="qrcode">
|
||||||
|
<QrcodeOutlined /> {{ t('qrCode') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<template v-if="record.isMultiUser()">
|
||||||
|
<a-menu-item key="addClient">
|
||||||
|
<UserAddOutlined /> {{ t('pages.client.add') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="addBulkClient">
|
||||||
|
<UsergroupAddOutlined /> {{ t('pages.client.bulk') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="copyClients">
|
||||||
|
<CopyOutlined /> {{ t('pages.client.copyFromInbound') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="resetClients">
|
||||||
|
<FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="export">
|
||||||
|
<ExportOutlined /> {{ t('pages.inbounds.export') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item v-if="subEnable" key="subs">
|
||||||
|
<ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="delDepletedClients" class="danger-item">
|
||||||
|
<RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
|
||||||
|
</a-menu-item>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<a-menu-item key="showInfo">
|
||||||
|
<InfoCircleOutlined /> {{ t('info') }}
|
||||||
|
</a-menu-item>
|
||||||
|
</template>
|
||||||
|
<a-menu-item key="clipboard">
|
||||||
|
<CopyOutlined /> {{ t('pages.inbounds.exportInbound') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="resetTraffic">
|
||||||
|
<RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="clone">
|
||||||
|
<BlockOutlined /> {{ t('pages.inbounds.clone') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="delete" class="danger-item">
|
||||||
|
<DeleteOutlined /> {{ t('delete') }}
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</template>
|
||||||
|
</a-dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2-column labelled stat grid: protocol/port/node + traffic/clients/expiry -->
|
||||||
|
<div class="card-stats">
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('pages.inbounds.protocol') }}</span>
|
||||||
|
<a-tag color="purple">{{ record.protocol }}</a-tag>
|
||||||
|
<template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS">
|
||||||
|
<a-tag color="green">{{ record.toInbound().stream.network }}</a-tag>
|
||||||
|
<a-tag v-if="record.toInbound().stream.isTls" color="blue">TLS</a-tag>
|
||||||
|
<a-tag v-if="record.toInbound().stream.isReality" color="blue">Reality</a-tag>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('pages.inbounds.port') }}</span>
|
||||||
|
<a-tag>{{ record.port }}</a-tag>
|
||||||
|
</div>
|
||||||
|
<div v-if="nodesById.size > 0" class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('pages.inbounds.node') }}</span>
|
||||||
|
<a-tag v-if="record.nodeId == null" color="default">
|
||||||
|
{{ t('pages.inbounds.localPanel') }}
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-else-if="nodesById.get(record.nodeId)"
|
||||||
|
:color="nodesById.get(record.nodeId).status === 'online' ? 'blue' : 'red'">
|
||||||
|
{{ nodesById.get(record.nodeId).name }}
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-else color="orange">#{{ record.nodeId }}</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
|
||||||
|
<a-tag :color="ColorUtils.usageColor(record.up + record.down, trafficDiff, record.total)">
|
||||||
|
{{ SizeFormatter.sizeFormat(record.up + record.down) }} /
|
||||||
|
<template v-if="record.total > 0">{{ SizeFormatter.sizeFormat(record.total) }}</template>
|
||||||
|
<InfinityIcon v-else />
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
|
||||||
|
<a-tag>{{ SizeFormatter.sizeFormat(record.allTime || 0) }}</a-tag>
|
||||||
|
</div>
|
||||||
|
<div v-if="clientCount[record.id]" class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('clients') }}</span>
|
||||||
|
<a-tag color="green">{{ clientCount[record.id].clients }}</a-tag>
|
||||||
|
<a-tag v-if="clientCount[record.id].online.length" color="blue">
|
||||||
|
{{ clientCount[record.id].online.length }} {{ t('online') }}
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-if="clientCount[record.id].depleted.length" color="red">
|
||||||
|
{{ clientCount[record.id].depleted.length }} {{ t('depleted') }}
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-if="clientCount[record.id].expiring.length" color="orange">
|
||||||
|
{{ clientCount[record.id].expiring.length }} {{ t('depletingSoon') }}
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('pages.inbounds.expireDate') }}</span>
|
||||||
|
<a-tag v-if="record.expiryTime > 0"
|
||||||
|
:color="ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)">
|
||||||
|
{{ IntlUtil.formatRelativeTime(record.expiryTime) }}
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-else color="purple"><InfinityIcon /></a-tag>
|
||||||
|
</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"
|
||||||
|
@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)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ====================== Desktop: a-table ======================== -->
|
||||||
|
<a-table v-else :columns="columns" :data-source="visibleInbounds" :row-key="(r) => r.id"
|
||||||
|
:pagination="paginationFor(visibleInbounds)" :scroll="{ x: 1000 }"
|
||||||
:style="{ marginTop: '10px' }" size="small"
|
:style="{ marginTop: '10px' }" size="small"
|
||||||
:row-class-name="(r) => (r.isMultiUser() ? '' : 'hide-expand-icon')">
|
:row-class-name="(r) => (r.isMultiUser() ? '' : 'hide-expand-icon')">
|
||||||
<!-- Per-inbound client list, expanded by clicking the row's
|
<!-- Per-inbound client list, expanded by clicking the row's
|
||||||
|
|
@ -440,49 +595,6 @@ function showQrCodeMenu(dbInbound) {
|
||||||
</a-tag>
|
</a-tag>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- ============== Mobile info popover ============== -->
|
|
||||||
<template v-else-if="column.key === 'info'">
|
|
||||||
<a-popover placement="bottomRight" trigger="click">
|
|
||||||
<template #content>
|
|
||||||
<table cellpadding="2">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>{{ t('pages.inbounds.protocol') }}</td>
|
|
||||||
<td><a-tag color="purple">{{ record.protocol }}</a-tag></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>{{ t('pages.inbounds.port') }}</td>
|
|
||||||
<td><a-tag>{{ record.port }}</a-tag></td>
|
|
||||||
</tr>
|
|
||||||
<tr v-if="clientCount[record.id]">
|
|
||||||
<td>{{ t('clients') }}</td>
|
|
||||||
<td><a-tag color="blue">{{ clientCount[record.id].clients }}</a-tag></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>{{ t('pages.inbounds.traffic') }}</td>
|
|
||||||
<td>
|
|
||||||
<a-tag>
|
|
||||||
{{ SizeFormatter.sizeFormat(record.up + record.down) }} /
|
|
||||||
<template v-if="record.total > 0">{{ SizeFormatter.sizeFormat(record.total) }}</template>
|
|
||||||
<InfinityIcon v-else />
|
|
||||||
</a-tag>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>{{ t('pages.inbounds.expireDate') }}</td>
|
|
||||||
<td>
|
|
||||||
<a-tag v-if="record.expiryTime > 0">{{ IntlUtil.formatRelativeTime(record.expiryTime) }}</a-tag>
|
|
||||||
<a-tag v-else color="purple">
|
|
||||||
<InfinityIcon />
|
|
||||||
</a-tag>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</template>
|
|
||||||
<InfoCircleOutlined class="row-info-trigger" />
|
|
||||||
</a-popover>
|
|
||||||
</template>
|
|
||||||
</template>
|
</template>
|
||||||
</a-table>
|
</a-table>
|
||||||
</a-space>
|
</a-space>
|
||||||
|
|
@ -510,8 +622,7 @@ function showQrCodeMenu(dbInbound) {
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-action-trigger,
|
.row-action-trigger {
|
||||||
.row-info-trigger {
|
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
@ -566,54 +677,124 @@ function showQrCodeMenu(dbInbound) {
|
||||||
border-end-end-radius: 8px;
|
border-end-end-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Mobile-tightening ============================================
|
/* ===== Mobile card list ===========================================
|
||||||
* Below 768px the inbound list is on a tiny viewport — squeeze the
|
* <768px renders inbounds as a vertical stack of cards via the
|
||||||
* card chrome and table cell padding so the actual rows have room. */
|
* v-if="isMobile" branch above; the desktop <a-table> isn't mounted
|
||||||
|
* so the legacy table-cell tightening rules went away. */
|
||||||
|
.inbound-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbound-card {
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
:global(body.dark) .inbound-card {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.card-id {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.tag-name {
|
||||||
|
font-weight: 600;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
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;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.stat-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.stat-label {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
opacity: 0.6;
|
||||||
|
min-width: 96px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.card-stats :deep(.ant-tag) {
|
||||||
|
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;
|
||||||
|
opacity: 0.4;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
/* Card header/body breathe less on mobile */
|
|
||||||
:deep(.ant-card-head) {
|
:deep(.ant-card-head) {
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.ant-card-head-title),
|
:deep(.ant-card-head-title),
|
||||||
:deep(.ant-card-extra) {
|
:deep(.ant-card-extra) {
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.ant-card-body) {
|
:deep(.ant-card-body) {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Filter bar wraps cleanly without forcing block layout (which made
|
|
||||||
* the input + radio group stack on separate full-width lines). */
|
|
||||||
.filter-bar.mobile {
|
.filter-bar.mobile {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-bar.mobile > * {
|
.filter-bar.mobile > * {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tighten table cell padding so the 3 visible columns get room. */
|
.row-action-trigger {
|
||||||
:deep(.ant-table-thead > tr > th),
|
|
||||||
:deep(.ant-table-tbody > tr > td) {
|
|
||||||
padding: 8px 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Slightly bigger expand chevron (touch target). */
|
|
||||||
:deep(.ant-table-row-expand-icon) {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
line-height: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The action / info icons are the row's primary touch targets. */
|
|
||||||
.row-action-trigger,
|
|
||||||
.row-info-trigger {
|
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -549,12 +549,12 @@ function onRowAction({ key, dbInbound }) {
|
||||||
<a-spin :spinning="!fetched" :delay="200" tip="Loading…" size="large">
|
<a-spin :spinning="!fetched" :delay="200" tip="Loading…" size="large">
|
||||||
<div v-if="!fetched" class="loading-spacer" />
|
<div v-if="!fetched" class="loading-spacer" />
|
||||||
|
|
||||||
<a-row v-else :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]">
|
<a-row v-else :gutter="[isMobile ? 8 : 16, 12]">
|
||||||
<!-- Summary statistics card -->
|
<!-- Summary statistics card -->
|
||||||
<a-col :span="24">
|
<a-col :span="24">
|
||||||
<a-card size="small" hoverable class="summary-card">
|
<a-card size="small" hoverable class="summary-card">
|
||||||
<a-row :gutter="[16, 12]">
|
<a-row :gutter="[16, 12]">
|
||||||
<a-col :sm="12" :md="5">
|
<a-col :xs="12" :sm="12" :md="5">
|
||||||
<CustomStatistic :title="t('pages.inbounds.totalDownUp')"
|
<CustomStatistic :title="t('pages.inbounds.totalDownUp')"
|
||||||
:value="`${SizeFormatter.sizeFormat(totals.up)} / ${SizeFormatter.sizeFormat(totals.down)}`">
|
:value="`${SizeFormatter.sizeFormat(totals.up)} / ${SizeFormatter.sizeFormat(totals.down)}`">
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
|
|
@ -562,7 +562,7 @@ function onRowAction({ key, dbInbound }) {
|
||||||
</template>
|
</template>
|
||||||
</CustomStatistic>
|
</CustomStatistic>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :sm="12" :md="5">
|
<a-col :xs="12" :sm="12" :md="5">
|
||||||
<CustomStatistic :title="t('pages.inbounds.totalUsage')"
|
<CustomStatistic :title="t('pages.inbounds.totalUsage')"
|
||||||
:value="SizeFormatter.sizeFormat(totals.up + totals.down)">
|
:value="SizeFormatter.sizeFormat(totals.up + totals.down)">
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
|
|
@ -570,7 +570,7 @@ function onRowAction({ key, dbInbound }) {
|
||||||
</template>
|
</template>
|
||||||
</CustomStatistic>
|
</CustomStatistic>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :sm="12" :md="5">
|
<a-col :xs="12" :sm="12" :md="5">
|
||||||
<CustomStatistic :title="t('pages.inbounds.allTimeTrafficUsage')"
|
<CustomStatistic :title="t('pages.inbounds.allTimeTrafficUsage')"
|
||||||
:value="SizeFormatter.sizeFormat(totals.allTime)">
|
:value="SizeFormatter.sizeFormat(totals.allTime)">
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
|
|
@ -578,14 +578,14 @@ function onRowAction({ key, dbInbound }) {
|
||||||
</template>
|
</template>
|
||||||
</CustomStatistic>
|
</CustomStatistic>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :sm="12" :md="5">
|
<a-col :xs="12" :sm="12" :md="5">
|
||||||
<CustomStatistic :title="t('pages.inbounds.inboundCount')" :value="String(dbInbounds.length)">
|
<CustomStatistic :title="t('pages.inbounds.inboundCount')" :value="String(dbInbounds.length)">
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<BarsOutlined />
|
<BarsOutlined />
|
||||||
</template>
|
</template>
|
||||||
</CustomStatistic>
|
</CustomStatistic>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :sm="24" :md="4">
|
<a-col :xs="24" :sm="24" :md="4">
|
||||||
<CustomStatistic :title="t('clients')" value=" ">
|
<CustomStatistic :title="t('clients')" value=" ">
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<a-space direction="horizontal">
|
<a-space direction="horizontal">
|
||||||
|
|
|
||||||
|
|
@ -677,7 +677,7 @@ export class CookieManager {
|
||||||
// "no quota / no expiry / unlimited" sentinel since the AD-Vue green
|
// "no quota / no expiry / unlimited" sentinel since the AD-Vue green
|
||||||
// would otherwise read as "healthy / under limit".
|
// would otherwise read as "healthy / under limit".
|
||||||
const COLORS = {
|
const COLORS = {
|
||||||
success: '#52c41a', // AD-Vue success — within quota
|
success: '#389e0a', // AD-Vue green-7 — within quota (toned down from green-6 #52c41a, which was too bright on dark themes)
|
||||||
warning: '#faad14', // AD-Vue gold — close to quota / about to expire
|
warning: '#faad14', // AD-Vue gold — close to quota / about to expire
|
||||||
danger: '#ff4d4f', // AD-Vue red — depleted / expired
|
danger: '#ff4d4f', // AD-Vue red — depleted / expired
|
||||||
purple: '#722ed1', // AD-Vue purple — unlimited / no expiry
|
purple: '#722ed1', // AD-Vue purple — unlimited / no expiry
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue