diff --git a/frontend/src/components/InfinityIcon.vue b/frontend/src/components/InfinityIcon.vue new file mode 100644 index 00000000..a03eb26a --- /dev/null +++ b/frontend/src/components/InfinityIcon.vue @@ -0,0 +1,25 @@ + + + diff --git a/frontend/src/pages/inbounds/ClientRowTable.vue b/frontend/src/pages/inbounds/ClientRowTable.vue index fc6d35c4..eaaa4255 100644 --- a/frontend/src/pages/inbounds/ClientRowTable.vue +++ b/frontend/src/pages/inbounds/ClientRowTable.vue @@ -12,17 +12,13 @@ import { import { Modal } from 'ant-design-vue'; import { SizeFormatter, IntlUtil, ColorUtils } from '@/utils'; +import InfinityIcon from '@/components/InfinityIcon.vue'; const { t } = useI18n(); -// 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). +// Per-inbound expand-row content. CSS-grid layout (not a nested +// ) 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 }, @@ -43,17 +39,10 @@ const emit = defineEmits([ '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); @@ -116,33 +105,35 @@ function clientStatsColor(email) { return ColorUtils.clientUsageColor(statsFor(email), props.trafficDiff); } function statsExpColor(email) { - if (!email) return '#7a316f'; + // 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 '#7a316f'; + 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 '#cf3c3c'; - if (a === 'orange' || b === 'orange') return '#f37b24'; - if (a === 'green' || b === 'green') return '#008771'; - return '#7a316f'; + if (a === 'red' || b === 'red') return DANGER; + if (a === 'orange' || b === 'orange') return WARN; + if (a === 'green' || b === 'green') return SUCCESS; + return PURPLE; } -// === 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'. + 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 (mounted on the row, not a modal) ============== +// === Action confirms ============================================== function confirmReset(client) { Modal.confirm({ title: `${t('pages.inbounds.resetTraffic')} — ${client.email}`, @@ -163,238 +154,203 @@ function confirmDelete(client) { }); } -// === Columns ======================================================== -// Two layouts: desktop has icon-row actions across; mobile collapses -// the per-row actions into a single dropdown + an info popover. -// Computed so column titles re-render after a locale swap. -const desktopColumns = computed(() => [ - { title: t('pages.settings.actions'), key: 'actions', width: 140 }, - { title: t('enable'), key: 'enable', width: 60 }, - { title: t('online'), key: 'online', width: 80 }, - { title: t('pages.inbounds.client'), key: 'client', width: 160 }, - { title: t('pages.inbounds.traffic'), key: 'traffic', align: 'center', width: 200 }, - { title: t('pages.inbounds.allTimeTraffic'), key: 'allTime', align: 'center', width: 110 }, - { title: t('pages.inbounds.expireDate'), key: 'expiryTime', align: 'center', width: 180 }, -]); -const mobileColumns = computed(() => [ - { title: t('pages.settings.actions'), key: 'actionMenu', align: 'center', width: 10 }, - { title: t('pages.inbounds.client'), key: 'client', align: 'left', width: 90 }, - { title: t('info'), key: 'info', align: 'center', width: 10 }, -]); - -const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopColumns.value)); +// 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); +} @@ -501,7 +502,7 @@ function showQrCodeMenu(dbInbound) { {{ SizeFormatter.sizeFormat(record.up + record.down) }} / - + @@ -509,7 +510,7 @@ function showQrCodeMenu(dbInbound) { {{ t('pages.inbounds.expireDate') }} {{ IntlUtil.formatRelativeTime(record.expiryTime) }} - + diff --git a/frontend/src/utils/legacy.js b/frontend/src/utils/legacy.js index 5549145c..f1873d6b 100644 --- a/frontend/src/utils/legacy.js +++ b/frontend/src/utils/legacy.js @@ -670,6 +670,17 @@ export class CookieManager { } } +// AD-Vue 4 semantic palette — kept in one place so the client/inbound +// rows match the rest of the panel. Purple is reserved for the +// "no quota / no expiry / unlimited" sentinel since the AD-Vue green +// would otherwise read as "healthy / under limit". +const COLORS = { + success: '#52c41a', // AD-Vue success — within quota + warning: '#faad14', // AD-Vue gold — close to quota / about to expire + danger: '#ff4d4f', // AD-Vue red — depleted / expired + purple: '#722ed1', // AD-Vue purple — unlimited / no expiry +}; + export class ColorUtils { static usageColor(data, threshold, total) { switch (true) { @@ -684,10 +695,10 @@ export class ColorUtils { static clientUsageColor(clientStats, trafficDiff) { switch (true) { - case !clientStats || clientStats.total == 0: return "#7a316f"; - case clientStats.up + clientStats.down < clientStats.total - trafficDiff: return "#008771"; - case clientStats.up + clientStats.down < clientStats.total: return "#f37b24"; - default: return "#cf3c3c"; + case !clientStats || clientStats.total == 0: return COLORS.purple; + case clientStats.up + clientStats.down < clientStats.total - trafficDiff: return COLORS.success; + case clientStats.up + clientStats.down < clientStats.total: return COLORS.warning; + default: return COLORS.danger; } } @@ -695,12 +706,12 @@ export class ColorUtils { if (!client.enable) return isDark ? '#2c3950' : '#bcbcbc'; let now = new Date().getTime(), expiry = client.expiryTime; switch (true) { - case expiry === null: return "#7a316f"; - case expiry < 0: return "#008771"; - case expiry == 0: return "#7a316f"; - case now < expiry - threshold: return "#008771"; - case now < expiry: return "#f37b24"; - default: return "#cf3c3c"; + case expiry === null: return COLORS.purple; + case expiry < 0: return COLORS.success; + case expiry == 0: return COLORS.purple; + case now < expiry - threshold: return COLORS.success; + case now < expiry: return COLORS.warning; + default: return COLORS.danger; } } }