| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780 |
- <script setup>
- import { computed, ref, watch } from 'vue';
- import { useI18n } from 'vue-i18n';
- import {
- EditOutlined,
- InfoCircleOutlined,
- QrcodeOutlined,
- RetweetOutlined,
- DeleteOutlined,
- EllipsisOutlined,
- } from '@ant-design/icons-vue';
- import { Modal } from 'ant-design-vue';
- import { SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
- import InfinityIcon from '@/components/InfinityIcon.vue';
- import { useDatepicker } from '@/composables/useDatepicker.js';
- const { datepicker } = useDatepicker();
- const { t } = useI18n();
- // Per-inbound expand-row content. CSS-grid layout (not a nested
- // <a-table>) so it sits flush inside the parent's expanded cell.
- // No API calls here — events bubble to the parent's modals.
- const props = defineProps({
- dbInbound: { type: Object, required: true },
- isMobile: { type: Boolean, default: false },
- trafficDiff: { type: Number, default: 0 },
- expireDiff: { type: Number, default: 0 },
- onlineClients: { type: Array, default: () => [] },
- lastOnlineMap: { type: Object, default: () => ({}) },
- isDarkTheme: { type: Boolean, default: false },
- });
- const emit = defineEmits([
- 'edit-client',
- 'qrcode-client',
- 'info-client',
- 'reset-traffic-client',
- 'delete-client',
- 'delete-clients',
- 'toggle-enable-client',
- ]);
- const inbound = computed(() => props.dbInbound.toInbound());
- const clients = computed(() => inbound.value?.clients || []);
- // === Per-client stats lookup =======================================
- 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, datepicker.value);
- }
- function statsProgress(email) {
- const s = statsFor(email);
- if (!s) return 0;
- if (s.total === 0) return 100;
- return (100 * (s.down + s.up)) / s.total;
- }
- function expireProgress(expTime, reset) {
- const now = Date.now();
- const remainedSec = expTime < 0 ? -expTime / 1000 : (expTime - now) / 1000;
- const resetSec = reset * 86400;
- if (remainedSec >= resetSec) return 0;
- return 100 * (1 - remainedSec / resetSec);
- }
- function clientStatsColor(email) {
- return ColorUtils.clientUsageColor(statsFor(email), props.trafficDiff);
- }
- function statsExpColor(email) {
- // AD-Vue 4 semantic palette mirrors ColorUtils.* so the badge dot
- // matches the row's traffic/expiry tags.
- const PURPLE = '#722ed1', SUCCESS = '#52c41a', WARN = '#faad14', DANGER = '#ff4d4f';
- if (!email) return PURPLE;
- const s = statsFor(email);
- if (!s) return PURPLE;
- const a = ColorUtils.usageColor(s.down + s.up, props.trafficDiff, s.total);
- const b = ColorUtils.usageColor(Date.now(), props.expireDiff, s.expiryTime);
- if (a === 'red' || b === 'red') return DANGER;
- if (a === 'orange' || b === 'orange') return WARN;
- if (a === 'green' || b === 'green') return SUCCESS;
- return PURPLE;
- }
- const isRemovable = computed(() => clients.value.length > 1);
- function totalGbDisplay(client) {
- if (!client.totalGB || client.totalGB <= 0) return '';
- return `${Math.round((client.totalGB / 1073741824) * 100) / 100} GB`;
- }
- const isUnlimitedTotal = (client) => !client.totalGB || client.totalGB <= 0;
- function statusBadgeColor(client) {
- if (!client.enable) return props.isDarkTheme ? '#2c3950' : '#bcbcbc';
- return statsExpColor(client.email);
- }
- // === Action confirms ==============================================
- function confirmReset(client) {
- Modal.confirm({
- title: `${t('pages.inbounds.resetTraffic')} — ${client.email}`,
- content: t('pages.inbounds.resetTrafficContent'),
- okText: t('reset'),
- cancelText: t('cancel'),
- onOk: () => emit('reset-traffic-client', { dbInbound: props.dbInbound, client }),
- });
- }
- function confirmDelete(client) {
- Modal.confirm({
- title: `${t('pages.inbounds.deleteClient')} — ${client.email}`,
- content: t('pages.inbounds.deleteClientContent'),
- okText: t('delete'),
- okType: 'danger',
- cancelText: t('cancel'),
- onOk: () => emit('delete-client', { dbInbound: props.dbInbound, client }),
- });
- }
- // Stable row key for v-for — falls back through email/id/password
- // because not every protocol fills the same field.
- function rowKey(client) {
- return client.email || client.id || client.password || JSON.stringify(client);
- }
- const selected = ref(new Set());
- const allSelected = computed(() =>
- clients.value.length > 0 && clients.value.every((c) => selected.value.has(rowKey(c))),
- );
- const someSelected = computed(() =>
- clients.value.some((c) => selected.value.has(rowKey(c))),
- );
- const selectedCount = computed(() => selected.value.size);
- function isSelected(key) {
- return selected.value.has(key);
- }
- function toggleSelect(key, next) {
- const s = new Set(selected.value);
- if (next) s.add(key); else s.delete(key);
- selected.value = s;
- }
- function selectAll(next) {
- if (next) {
- selected.value = new Set(clients.value.map(rowKey));
- } else {
- selected.value = new Set();
- }
- }
- function clearSelection() {
- selected.value = new Set();
- }
- watch(clients, (list) => {
- if (selected.value.size === 0) return;
- const valid = new Set(list.map(rowKey));
- const next = new Set();
- for (const k of selected.value) if (valid.has(k)) next.add(k);
- if (next.size !== selected.value.size) selected.value = next;
- });
- 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>
- <template>
- <div class="client-list"
- :class="{ 'is-mobile': isMobile, 'is-dark': isDarkTheme, 'has-select': isRemovable }">
- <div v-if="isRemovable && selectedCount > 0" class="bulk-bar">
- <span class="bulk-count">{{ selectedCount }} selected</span>
- <a-button size="small" type="link" @click="clearSelection">{{ t('cancel') }}</a-button>
- <a-button size="small" danger @click="confirmBulkDelete">
- <DeleteOutlined /> {{ t('delete') }}
- </a-button>
- </div>
- <!-- ====================== Desktop: grid table ===================== -->
- <template v-if="!isMobile">
- <div class="client-row client-list-header">
- <div v-if="isRemovable" class="cell cell-select">
- <a-checkbox :checked="allSelected" :indeterminate="someSelected && !allSelected"
- @change="(e) => selectAll(e.target.checked)" />
- </div>
- <div class="cell cell-actions">{{ t('pages.settings.actions') }}</div>
- <div class="cell cell-enable">{{ t('enable') }}</div>
- <div class="cell cell-online">{{ t('online') }}</div>
- <div class="cell cell-client">{{ t('pages.inbounds.client') }}</div>
- <div class="cell cell-traffic">{{ t('pages.inbounds.traffic') }}</div>
- <div class="cell cell-remained">{{ t('remained') }}</div>
- <div class="cell cell-alltime">{{ t('pages.inbounds.allTimeTraffic') }}</div>
- <div class="cell cell-expiry">{{ t('pages.inbounds.expireDate') }}</div>
- </div>
- <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">
- <a-tooltip v-if="dbInbound.hasLink()" :title="t('qrCode')">
- <QrcodeOutlined class="row-icon" @click="emit('qrcode-client', { dbInbound, client })" />
- </a-tooltip>
- <a-tooltip :title="t('edit')">
- <EditOutlined class="row-icon" @click="emit('edit-client', { dbInbound, client })" />
- </a-tooltip>
- <a-tooltip :title="t('info')">
- <InfoCircleOutlined class="row-icon" @click="emit('info-client', { dbInbound, client })" />
- </a-tooltip>
- <a-tooltip v-if="client.email" :title="t('pages.inbounds.resetTraffic')">
- <RetweetOutlined class="row-icon" @click="confirmReset(client)" />
- </a-tooltip>
- <a-tooltip v-if="isRemovable" :title="t('delete')">
- <DeleteOutlined class="row-icon danger" @click="confirmDelete(client)" />
- </a-tooltip>
- </div>
- <div class="cell cell-enable">
- <a-switch :checked="client.enable" size="small"
- @change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
- </div>
- <div class="cell cell-online">
- <a-popover>
- <template #content>{{ t('lastOnline') }}: {{ lastOnlineLabel(client.email) }}</template>
- <a-tag v-if="client.enable && isClientOnline(client.email)" color="green">{{ t('online') }}</a-tag>
- <a-tag v-else>{{ t('offline') }}</a-tag>
- </a-popover>
- </div>
- <div class="cell cell-client">
- <a-tooltip>
- <template #title>
- <template v-if="isClientDepleted(client.email)">{{ t('depleted') }}</template>
- <template v-else-if="!client.enable">{{ t('disabled') }}</template>
- <template v-else-if="isClientOnline(client.email)">{{ t('online') }}</template>
- <template v-else>{{ t('offline') }}</template>
- </template>
- <a-badge :color="statusBadgeColor(client)" />
- </a-tooltip>
- <div class="client-id-stack">
- <a-tooltip :title="client.email">
- <span class="client-email">{{ client.email }}</span>
- </a-tooltip>
- <span v-if="client.comment && client.comment.trim()" class="client-comment">
- {{ client.comment.length > 50 ? client.comment.substring(0, 47) + '…' : client.comment }}
- </span>
- </div>
- </div>
- <div class="cell cell-traffic">
- <a-popover>
- <template v-if="client.email" #content>
- <table cellpadding="2">
- <tbody>
- <tr>
- <td>↑ {{ SizeFormatter.sizeFormat(getUp(client.email)) }}</td>
- <td>↓ {{ SizeFormatter.sizeFormat(getDown(client.email)) }}</td>
- </tr>
- <tr v-if="client.totalGB > 0">
- <td>{{ t('remained') }}</td>
- <td>{{ SizeFormatter.sizeFormat(getRem(client.email)) }}</td>
- </tr>
- </tbody>
- </table>
- </template>
- <div class="usage-bar">
- <span class="usage-text">{{ SizeFormatter.sizeFormat(getSum(client.email)) }}</span>
- <a-progress v-if="!client.enable" :stroke-color="isDarkTheme ? 'rgb(72,84,105)' : '#bcbcbc'"
- :show-info="false" :percent="statsProgress(client.email)" size="small" />
- <a-progress v-else-if="client.totalGB > 0" :stroke-color="clientStatsColor(client.email)"
- :show-info="false" :status="isClientDepleted(client.email) ? 'exception' : ''"
- :percent="statsProgress(client.email)" size="small" />
- <a-progress v-else :show-info="false" :percent="100" stroke-color="#722ed1" size="small" />
- <span class="usage-text">
- <InfinityIcon v-if="isUnlimitedTotal(client)" />
- <template v-else>{{ totalGbDisplay(client) }}</template>
- </span>
- </div>
- </a-popover>
- </div>
- <div class="cell cell-remained">
- <a-tag v-if="isUnlimitedTotal(client)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
- <InfinityIcon />
- </a-tag>
- <a-tag v-else :color="isClientDepleted(client.email) ? 'red' : ''">
- {{ SizeFormatter.sizeFormat(getRem(client.email)) }}
- </a-tag>
- </div>
- <div class="cell cell-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>
- <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>
- </template>
- <!-- ====================== Mobile: card list ======================= -->
- <template v-else>
- <div v-for="client in clients" :key="rowKey(client)" class="client-card"
- :class="{ 'is-selected': isSelected(rowKey(client)) }">
- <div class="client-card-head">
- <a-checkbox v-if="isRemovable" :checked="isSelected(rowKey(client))"
- @change="(e) => toggleSelect(rowKey(client), e.target.checked)" />
- <a-tooltip>
- <template #title>
- <template v-if="isClientDepleted(client.email)">{{ t('depleted') }}</template>
- <template v-else-if="!client.enable">{{ t('disabled') }}</template>
- <template v-else-if="isClientOnline(client.email)">{{ t('online') }}</template>
- <template v-else>{{ t('offline') }}</template>
- </template>
- <a-badge :color="statusBadgeColor(client)" />
- </a-tooltip>
- <a-tooltip :title="client.email">
- <span class="client-email">{{ client.email }}</span>
- </a-tooltip>
- <div class="client-card-actions">
- <a-switch :checked="client.enable" size="small"
- @change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
- <a-dropdown :trigger="['click']" placement="bottomRight">
- <EllipsisOutlined class="row-icon" @click.prevent />
- <template #overlay>
- <a-menu>
- <a-menu-item v-if="dbInbound.hasLink()" @click="emit('qrcode-client', { dbInbound, client })">
- <QrcodeOutlined /> {{ t('qrCode') }}
- </a-menu-item>
- <a-menu-item @click="emit('edit-client', { dbInbound, client })">
- <EditOutlined /> {{ t('edit') }}
- </a-menu-item>
- <a-menu-item @click="emit('info-client', { dbInbound, client })">
- <InfoCircleOutlined /> {{ t('info') }}
- </a-menu-item>
- <a-menu-item v-if="client.email" @click="confirmReset(client)">
- <RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
- </a-menu-item>
- <a-menu-item v-if="isRemovable" @click="confirmDelete(client)">
- <DeleteOutlined /> <span class="danger">{{ t('delete') }}</span>
- </a-menu-item>
- </a-menu>
- </template>
- </a-dropdown>
- </div>
- </div>
- <div v-if="client.comment && client.comment.trim()" class="client-comment-line">
- {{ client.comment.length > 80 ? client.comment.substring(0, 77) + '…' : client.comment }}
- </div>
- <div class="client-card-foot">
- <div class="stat-row">
- <span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
- <a-tag :color="clientStatsColor(client.email)">
- {{ SizeFormatter.sizeFormat(getSum(client.email)) }} /
- <InfinityIcon v-if="isUnlimitedTotal(client)" />
- <template v-else>{{ totalGbDisplay(client) }}</template>
- </a-tag>
- </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">
- <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>
- </template>
- </div>
- </template>
- <style scoped>
- .client-list {
- margin: -8px 0;
- font-size: 13px;
- }
- .bulk-bar {
- display: flex;
- align-items: center;
- gap: 12px;
- padding: 6px 16px;
- background: rgba(22, 119, 255, 0.08);
- border-bottom: 1px solid rgba(22, 119, 255, 0.18);
- }
- .bulk-count {
- font-weight: 500;
- font-size: 13px;
- }
- .is-selected {
- background: rgba(22, 119, 255, 0.06);
- }
- .client-row {
- display: grid;
- /* Default — no select column (single-client inbounds). The .has-select
- * modifier below prepends the 40px checkbox column. */
- grid-template-columns:
- 140px
- /* actions */
- 60px
- /* enable */
- 80px
- /* online */
- minmax(160px, 2fr)
- /* client identity */
- minmax(160px, 2fr)
- /* traffic */
- 130px
- /* all-time */
- 130px
- /* remained */
- 140px;
- /* expiry */
- gap: 12px;
- align-items: center;
- padding: 8px 16px;
- border-top: 1px solid rgba(128, 128, 128, 0.12);
- }
- .client-list.has-select .client-row {
- grid-template-columns:
- 40px
- /* select */
- 140px
- /* actions */
- 60px
- /* enable */
- 80px
- /* online */
- minmax(160px, 2fr)
- /* client identity */
- minmax(160px, 2fr)
- /* traffic */
- 130px
- /* all-time */
- 130px
- /* remained */
- 140px;
- /* expiry */
- }
- .client-row:last-child {
- border-bottom: 1px solid rgba(128, 128, 128, 0.12);
- }
- .client-list-header {
- font-weight: 500;
- font-size: 12px;
- opacity: 0.65;
- padding-top: 6px;
- padding-bottom: 6px;
- border-top: none;
- text-transform: uppercase;
- letter-spacing: 0.02em;
- }
- .cell {
- min-width: 0;
- /* allow grid children to shrink instead of overflowing */
- }
- .cell-select,
- .cell-actions,
- .cell-enable,
- .cell-online,
- .cell-alltime,
- .cell-remained {
- text-align: center;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 6px;
- flex-wrap: wrap;
- }
- .cell-actions {
- justify-content: flex-start;
- }
- .cell-client {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- min-width: 0;
- }
- .cell-traffic,
- .cell-expiry {
- text-align: center;
- }
- .client-list-header .cell {
- text-align: center;
- }
- .client-list-header .cell-actions,
- .client-list-header .cell-client {
- text-align: left;
- }
- /* Action icons */
- .row-icon {
- font-size: 16px;
- cursor: pointer;
- padding: 0 2px;
- color: inherit;
- transition: color 120ms ease;
- }
- .row-icon:hover {
- color: var(--ant-color-primary, #1677ff);
- }
- .row-icon.danger {
- color: #ff4d4f;
- }
- .danger {
- color: #ff4d4f;
- }
- /* Client identity stack (badge + email + comment) */
- .client-id-stack {
- display: flex;
- flex-direction: column;
- gap: 2px;
- min-width: 0;
- overflow: hidden;
- }
- .client-email {
- font-weight: 500;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- display: inline-block;
- }
- .client-comment {
- font-size: 11px;
- opacity: 0.7;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- display: inline-block;
- }
- /* Traffic / expiry inline bar: text | progress | text */
- .usage-bar {
- display: grid;
- grid-template-columns: minmax(50px, auto) minmax(40px, 1fr) minmax(40px, auto);
- align-items: center;
- gap: 6px;
- }
- .usage-text {
- font-size: 12px;
- white-space: nowrap;
- }
- .usage-bar :deep(.ant-progress) {
- margin: 0;
- line-height: 1;
- }
- .infinite-tag {
- min-width: 50px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- }
- /* Strip AD-Vue's default expanded-cell padding so the desktop grid
- * sits flush against the inbound row's left/right edges. */
- :deep(.ant-table-expanded-row > .ant-table-cell) {
- padding: 0 !important;
- }
- /* ===== Mobile card list =========================================== */
- .client-list.is-mobile {
- display: flex;
- flex-direction: column;
- gap: 8px;
- margin: 0;
- }
- .client-card {
- border: 1px solid rgba(128, 128, 128, 0.18);
- border-radius: 8px;
- padding: 10px 12px;
- display: flex;
- flex-direction: column;
- gap: 6px;
- }
- :global(body.dark) .client-card {
- border-color: rgba(255, 255, 255, 0.1);
- }
- .client-card-head {
- display: flex;
- align-items: center;
- gap: 8px;
- min-width: 0;
- }
- .client-card-head .client-email {
- flex: 1;
- min-width: 0;
- font-size: 14px;
- font-weight: 500;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .client-card-actions {
- margin-left: auto;
- display: flex;
- align-items: center;
- gap: 8px;
- flex-shrink: 0;
- }
- .client-card-actions .row-icon {
- font-size: 20px;
- padding: 4px;
- }
- .client-comment-line {
- font-size: 11px;
- opacity: 0.7;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .client-card-foot {
- display: flex;
- flex-direction: column;
- gap: 4px;
- }
- .client-card-foot .stat-row {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 6px;
- }
- .client-card-foot .stat-label {
- font-size: 10px;
- text-transform: uppercase;
- letter-spacing: 0.04em;
- opacity: 0.6;
- min-width: 96px;
- flex-shrink: 0;
- }
- .client-card-foot :deep(.ant-tag) {
- margin: 0;
- }
- /* Bigger status badge for thumb-readable state at a glance. */
- .client-card-head :deep(.ant-badge-status-dot) {
- width: 9px;
- height: 9px;
- }
- </style>
|