|
@@ -1,5 +1,5 @@
|
|
|
<script setup>
|
|
<script setup>
|
|
|
-import { computed } from 'vue';
|
|
|
|
|
|
|
+import { computed, ref, watch } from 'vue';
|
|
|
import { useI18n } from 'vue-i18n';
|
|
import { useI18n } from 'vue-i18n';
|
|
|
import {
|
|
import {
|
|
|
EditOutlined,
|
|
EditOutlined,
|
|
@@ -39,6 +39,7 @@ const emit = defineEmits([
|
|
|
'info-client',
|
|
'info-client',
|
|
|
'reset-traffic-client',
|
|
'reset-traffic-client',
|
|
|
'delete-client',
|
|
'delete-client',
|
|
|
|
|
+ 'delete-clients',
|
|
|
'toggle-enable-client',
|
|
'toggle-enable-client',
|
|
|
]);
|
|
]);
|
|
|
|
|
|
|
@@ -162,23 +163,95 @@ function confirmDelete(client) {
|
|
|
function rowKey(client) {
|
|
function rowKey(client) {
|
|
|
return client.email || client.id || client.password || JSON.stringify(client);
|
|
return client.email || client.id || client.password || JSON.stringify(client);
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+const selected = ref(new Set());
|
|
|
|
|
+
|
|
|
|
|
+const allSelected = computed(() =>
|
|
|
|
|
+ clients.value.length > 0 && clients.value.every((c) => selected.value.has(rowKey(c))),
|
|
|
|
|
+);
|
|
|
|
|
+const someSelected = computed(() =>
|
|
|
|
|
+ clients.value.some((c) => selected.value.has(rowKey(c))),
|
|
|
|
|
+);
|
|
|
|
|
+const selectedCount = computed(() => selected.value.size);
|
|
|
|
|
+
|
|
|
|
|
+function isSelected(key) {
|
|
|
|
|
+ return selected.value.has(key);
|
|
|
|
|
+}
|
|
|
|
|
+function toggleSelect(key, next) {
|
|
|
|
|
+ const s = new Set(selected.value);
|
|
|
|
|
+ if (next) s.add(key); else s.delete(key);
|
|
|
|
|
+ selected.value = s;
|
|
|
|
|
+}
|
|
|
|
|
+function selectAll(next) {
|
|
|
|
|
+ if (next) {
|
|
|
|
|
+ selected.value = new Set(clients.value.map(rowKey));
|
|
|
|
|
+ } else {
|
|
|
|
|
+ selected.value = new Set();
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+function clearSelection() {
|
|
|
|
|
+ selected.value = new Set();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+watch(clients, (list) => {
|
|
|
|
|
+ if (selected.value.size === 0) return;
|
|
|
|
|
+ const valid = new Set(list.map(rowKey));
|
|
|
|
|
+ const next = new Set();
|
|
|
|
|
+ for (const k of selected.value) if (valid.has(k)) next.add(k);
|
|
|
|
|
+ if (next.size !== selected.value.size) selected.value = next;
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+function confirmBulkDelete() {
|
|
|
|
|
+ const picked = clients.value.filter((c) => selected.value.has(rowKey(c)));
|
|
|
|
|
+ if (picked.length === 0) return;
|
|
|
|
|
+ Modal.confirm({
|
|
|
|
|
+ title: t('pages.inbounds.deleteClient') + ` — ${picked.length}`,
|
|
|
|
|
+ content: t('pages.inbounds.deleteClientContent'),
|
|
|
|
|
+ okText: t('delete'),
|
|
|
|
|
+ okType: 'danger',
|
|
|
|
|
+ cancelText: t('cancel'),
|
|
|
|
|
+ onOk: () => {
|
|
|
|
|
+ emit('delete-clients', { dbInbound: props.dbInbound, clients: picked });
|
|
|
|
|
+ clearSelection();
|
|
|
|
|
+ },
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
</script>
|
|
</script>
|
|
|
|
|
|
|
|
<template>
|
|
<template>
|
|
|
- <div class="client-list" :class="{ 'is-mobile': isMobile, 'is-dark': isDarkTheme }">
|
|
|
|
|
|
|
+ <div class="client-list"
|
|
|
|
|
+ :class="{ 'is-mobile': isMobile, 'is-dark': isDarkTheme, 'has-select': isRemovable }">
|
|
|
|
|
+ <div v-if="isRemovable && selectedCount > 0" class="bulk-bar">
|
|
|
|
|
+ <span class="bulk-count">{{ selectedCount }} selected</span>
|
|
|
|
|
+ <a-button size="small" type="link" @click="clearSelection">{{ t('cancel') }}</a-button>
|
|
|
|
|
+ <a-button size="small" danger @click="confirmBulkDelete">
|
|
|
|
|
+ <DeleteOutlined /> {{ t('delete') }}
|
|
|
|
|
+ </a-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
<!-- ====================== Desktop: grid table ===================== -->
|
|
<!-- ====================== Desktop: grid table ===================== -->
|
|
|
<template v-if="!isMobile">
|
|
<template v-if="!isMobile">
|
|
|
<div class="client-row client-list-header">
|
|
<div class="client-row client-list-header">
|
|
|
|
|
+ <div v-if="isRemovable" class="cell cell-select">
|
|
|
|
|
+ <a-checkbox :checked="allSelected" :indeterminate="someSelected && !allSelected"
|
|
|
|
|
+ @change="(e) => selectAll(e.target.checked)" />
|
|
|
|
|
+ </div>
|
|
|
<div class="cell cell-actions">{{ t('pages.settings.actions') }}</div>
|
|
<div class="cell cell-actions">{{ t('pages.settings.actions') }}</div>
|
|
|
<div class="cell cell-enable">{{ t('enable') }}</div>
|
|
<div class="cell cell-enable">{{ t('enable') }}</div>
|
|
|
<div class="cell cell-online">{{ t('online') }}</div>
|
|
<div class="cell cell-online">{{ t('online') }}</div>
|
|
|
<div class="cell cell-client">{{ t('pages.inbounds.client') }}</div>
|
|
<div class="cell cell-client">{{ t('pages.inbounds.client') }}</div>
|
|
|
<div class="cell cell-traffic">{{ t('pages.inbounds.traffic') }}</div>
|
|
<div class="cell cell-traffic">{{ t('pages.inbounds.traffic') }}</div>
|
|
|
|
|
+ <div class="cell cell-remained">{{ t('remained') }}</div>
|
|
|
<div class="cell cell-alltime">{{ t('pages.inbounds.allTimeTraffic') }}</div>
|
|
<div class="cell cell-alltime">{{ t('pages.inbounds.allTimeTraffic') }}</div>
|
|
|
<div class="cell cell-expiry">{{ t('pages.inbounds.expireDate') }}</div>
|
|
<div class="cell cell-expiry">{{ t('pages.inbounds.expireDate') }}</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- <div v-for="client in clients" :key="rowKey(client)" class="client-row">
|
|
|
|
|
|
|
+ <div v-for="client in clients" :key="rowKey(client)" class="client-row"
|
|
|
|
|
+ :class="{ 'is-selected': isSelected(rowKey(client)) }">
|
|
|
|
|
+ <div v-if="isRemovable" class="cell cell-select">
|
|
|
|
|
+ <a-checkbox :checked="isSelected(rowKey(client))"
|
|
|
|
|
+ @change="(e) => toggleSelect(rowKey(client), e.target.checked)" />
|
|
|
|
|
+ </div>
|
|
|
<div class="cell cell-actions">
|
|
<div class="cell cell-actions">
|
|
|
<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 })" />
|
|
@@ -262,6 +335,15 @@ function rowKey(client) {
|
|
|
</a-popover>
|
|
</a-popover>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
|
|
+ <div class="cell cell-remained">
|
|
|
|
|
+ <a-tag v-if="isUnlimitedTotal(client)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
|
|
|
|
|
+ <InfinityIcon />
|
|
|
|
|
+ </a-tag>
|
|
|
|
|
+ <a-tag v-else :color="isClientDepleted(client.email) ? 'red' : ''">
|
|
|
|
|
+ {{ SizeFormatter.sizeFormat(getRem(client.email)) }}
|
|
|
|
|
+ </a-tag>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
<div class="cell cell-alltime">
|
|
<div class="cell cell-alltime">
|
|
|
<a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
|
|
<a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
|
|
|
</div>
|
|
</div>
|
|
@@ -301,8 +383,11 @@ function rowKey(client) {
|
|
|
|
|
|
|
|
<!-- ====================== Mobile: card list ======================= -->
|
|
<!-- ====================== Mobile: card list ======================= -->
|
|
|
<template v-else>
|
|
<template v-else>
|
|
|
- <div v-for="client in clients" :key="rowKey(client)" class="client-card">
|
|
|
|
|
|
|
+ <div v-for="client in clients" :key="rowKey(client)" class="client-card"
|
|
|
|
|
+ :class="{ 'is-selected': isSelected(rowKey(client)) }">
|
|
|
<div class="client-card-head">
|
|
<div class="client-card-head">
|
|
|
|
|
+ <a-checkbox v-if="isRemovable" :checked="isSelected(rowKey(client))"
|
|
|
|
|
+ @change="(e) => toggleSelect(rowKey(client), e.target.checked)" />
|
|
|
<a-tooltip>
|
|
<a-tooltip>
|
|
|
<template #title>
|
|
<template #title>
|
|
|
<template v-if="isClientDepleted(client.email)">{{ t('depleted') }}</template>
|
|
<template v-if="isClientDepleted(client.email)">{{ t('depleted') }}</template>
|
|
@@ -356,6 +441,15 @@ function rowKey(client) {
|
|
|
<template v-else>{{ totalGbDisplay(client) }}</template>
|
|
<template v-else>{{ totalGbDisplay(client) }}</template>
|
|
|
</a-tag>
|
|
</a-tag>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ <div class="stat-row">
|
|
|
|
|
+ <span class="stat-label">{{ t('remained') }}</span>
|
|
|
|
|
+ <a-tag v-if="isUnlimitedTotal(client)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
|
|
|
|
|
+ <InfinityIcon />
|
|
|
|
|
+ </a-tag>
|
|
|
|
|
+ <a-tag v-else :color="isClientDepleted(client.email) ? 'red' : ''">
|
|
|
|
|
+ {{ SizeFormatter.sizeFormat(getRem(client.email)) }}
|
|
|
|
|
+ </a-tag>
|
|
|
|
|
+ </div>
|
|
|
<div class="stat-row">
|
|
<div class="stat-row">
|
|
|
<span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
|
|
<span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
|
|
|
<a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
|
|
<a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
|
|
@@ -389,8 +483,28 @@ function rowKey(client) {
|
|
|
font-size: 13px;
|
|
font-size: 13px;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+.bulk-bar {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 12px;
|
|
|
|
|
+ padding: 6px 16px;
|
|
|
|
|
+ background: rgba(22, 119, 255, 0.08);
|
|
|
|
|
+ border-bottom: 1px solid rgba(22, 119, 255, 0.18);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.bulk-count {
|
|
|
|
|
+ font-weight: 500;
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.is-selected {
|
|
|
|
|
+ background: rgba(22, 119, 255, 0.06);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
.client-row {
|
|
.client-row {
|
|
|
display: grid;
|
|
display: grid;
|
|
|
|
|
+ /* Default — no select column (single-client inbounds). The .has-select
|
|
|
|
|
+ * modifier below prepends the 40px checkbox column. */
|
|
|
grid-template-columns:
|
|
grid-template-columns:
|
|
|
140px
|
|
140px
|
|
|
/* actions */
|
|
/* actions */
|
|
@@ -404,6 +518,8 @@ function rowKey(client) {
|
|
|
/* traffic */
|
|
/* traffic */
|
|
|
130px
|
|
130px
|
|
|
/* all-time */
|
|
/* all-time */
|
|
|
|
|
+ 130px
|
|
|
|
|
+ /* remained */
|
|
|
140px;
|
|
140px;
|
|
|
/* expiry */
|
|
/* expiry */
|
|
|
gap: 12px;
|
|
gap: 12px;
|
|
@@ -412,6 +528,28 @@ function rowKey(client) {
|
|
|
border-top: 1px solid rgba(128, 128, 128, 0.12);
|
|
border-top: 1px solid rgba(128, 128, 128, 0.12);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+.client-list.has-select .client-row {
|
|
|
|
|
+ grid-template-columns:
|
|
|
|
|
+ 40px
|
|
|
|
|
+ /* select */
|
|
|
|
|
+ 140px
|
|
|
|
|
+ /* actions */
|
|
|
|
|
+ 60px
|
|
|
|
|
+ /* enable */
|
|
|
|
|
+ 80px
|
|
|
|
|
+ /* online */
|
|
|
|
|
+ minmax(160px, 2fr)
|
|
|
|
|
+ /* client identity */
|
|
|
|
|
+ minmax(160px, 2fr)
|
|
|
|
|
+ /* traffic */
|
|
|
|
|
+ 130px
|
|
|
|
|
+ /* all-time */
|
|
|
|
|
+ 130px
|
|
|
|
|
+ /* remained */
|
|
|
|
|
+ 140px;
|
|
|
|
|
+ /* expiry */
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
.client-row:last-child {
|
|
.client-row:last-child {
|
|
|
border-bottom: 1px solid rgba(128, 128, 128, 0.12);
|
|
border-bottom: 1px solid rgba(128, 128, 128, 0.12);
|
|
|
}
|
|
}
|
|
@@ -432,10 +570,12 @@ function rowKey(client) {
|
|
|
/* allow grid children to shrink instead of overflowing */
|
|
/* allow grid children to shrink instead of overflowing */
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+.cell-select,
|
|
|
.cell-actions,
|
|
.cell-actions,
|
|
|
.cell-enable,
|
|
.cell-enable,
|
|
|
.cell-online,
|
|
.cell-online,
|
|
|
-.cell-alltime {
|
|
|
|
|
|
|
+.cell-alltime,
|
|
|
|
|
+.cell-remained {
|
|
|
text-align: center;
|
|
text-align: center;
|
|
|
display: inline-flex;
|
|
display: inline-flex;
|
|
|
align-items: center;
|
|
align-items: center;
|