diff --git a/frontend/src/pages/clients/ClientInfoModal.vue b/frontend/src/pages/clients/ClientInfoModal.vue new file mode 100644 index 00000000..5efb5f43 --- /dev/null +++ b/frontend/src/pages/clients/ClientInfoModal.vue @@ -0,0 +1,206 @@ + + + + + diff --git a/frontend/src/pages/clients/ClientQrModal.vue b/frontend/src/pages/clients/ClientQrModal.vue new file mode 100644 index 00000000..03114e38 --- /dev/null +++ b/frontend/src/pages/clients/ClientQrModal.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/frontend/src/pages/clients/ClientsPage.vue b/frontend/src/pages/clients/ClientsPage.vue index 0ebe57a2..8569056e 100644 --- a/frontend/src/pages/clients/ClientsPage.vue +++ b/frontend/src/pages/clients/ClientsPage.vue @@ -2,7 +2,15 @@ import { computed, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import { Modal, message } from 'ant-design-vue'; -import { PlusOutlined, UserOutlined } from '@ant-design/icons-vue'; +import { + PlusOutlined, + UserOutlined, + EditOutlined, + DeleteOutlined, + InfoCircleOutlined, + QrcodeOutlined, + RetweetOutlined, +} from '@ant-design/icons-vue'; import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js'; import { useMediaQuery } from '@/composables/useMediaQuery.js'; @@ -10,17 +18,21 @@ import AppSidebar from '@/components/AppSidebar.vue'; import { SizeFormatter, IntlUtil } from '@/utils'; import { useClients } from './useClients.js'; import ClientFormModal from './ClientFormModal.vue'; +import ClientInfoModal from './ClientInfoModal.vue'; +import ClientQrModal from './ClientQrModal.vue'; const { t } = useI18n(); const { clients, inbounds, + onlines, loading, fetched, create, update, remove, + resetTraffic, } = useClients(); const { isMobile } = useMediaQuery(); @@ -32,12 +44,23 @@ const formMode = ref('add'); const editingClient = ref(null); const editingAttachedIds = ref([]); +const infoOpen = ref(false); +const infoClient = ref(null); + +const qrOpen = ref(false); +const qrClient = ref(null); + +const onlineSet = computed(() => new Set(onlines.value || [])); const inboundsById = computed(() => { const out = {}; for (const ib of inbounds.value) out[ib.id] = ib; return out; }); +function isOnline(email) { + return !!email && onlineSet.value.has(email); +} + function inboundLabel(id) { const ib = inboundsById.value[id]; if (!ib) return `#${id}`; @@ -73,6 +96,34 @@ function onDelete(row) { }); } +function onResetTraffic(row) { + if (!row?.email || !Array.isArray(row.inboundIds) || row.inboundIds.length === 0) { + message.warning(t('pages.clients.resetNotPossible') || 'Attach this client to an inbound first.'); + return; + } + Modal.confirm({ + title: `${t('pages.inbounds.resetTraffic') || 'Reset traffic'} — ${row.email}`, + content: t('pages.inbounds.resetTrafficContent') + || 'Counters drop to zero. Quota and expiry stay as-is.', + okText: t('reset') || 'Reset', + cancelText: t('cancel'), + onOk: async () => { + const msg = await resetTraffic(row); + if (msg?.success) message.success(t('pages.clients.toasts.trafficReset') || 'Traffic reset'); + }, + }); +} + +function onShowInfo(row) { + infoClient.value = row; + infoOpen.value = true; +} + +function onShowQr(row) { + qrClient.value = row; + qrOpen.value = true; +} + async function onSave(payload, meta) { if (meta?.isEdit) { return update(meta.id, payload); @@ -89,19 +140,51 @@ function trafficLabel(row) { return `${SizeFormatter.sizeFormat(used)} / ${SizeFormatter.sizeFormat(total)}`; } +function remainingLabel(row) { + const total = row.totalGB || 0; + if (total <= 0) return '∞'; + const used = (row.traffic?.up || 0) + (row.traffic?.down || 0); + const r = total - used; + return r > 0 ? SizeFormatter.sizeFormat(r) : '0'; +} + +function remainingColor(row) { + const total = row.totalGB || 0; + if (total <= 0) return 'purple'; + const used = (row.traffic?.up || 0) + (row.traffic?.down || 0); + const ratio = used / total; + if (ratio >= 1) return 'red'; + if (ratio >= 0.85) return 'orange'; + return 'green'; +} + function expiryLabel(row) { - if (!row.expiryTime || row.expiryTime <= 0) return '-'; + if (!row.expiryTime || row.expiryTime <= 0) return '∞'; return IntlUtil.formatDate(row.expiryTime); } +function expiryRelative(row) { + if (!row.expiryTime || row.expiryTime <= 0) return ''; + return IntlUtil.formatRelativeTime(row.expiryTime); +} + +function expiryColor(row) { + if (!row.expiryTime || row.expiryTime <= 0) return 'purple'; + const now = Date.now(); + if (row.expiryTime <= now) return 'red'; + if (row.expiryTime - now < 86400 * 1000 * 3) return 'orange'; + return 'green'; +} + const columns = computed(() => [ - { title: t('pages.inbounds.client.email') || 'Email', dataIndex: 'email', key: 'email' }, - { title: 'subId', dataIndex: 'subId', key: 'subId' }, + { title: t('pages.inbounds.client.email') || 'Email', key: 'email' }, + { title: t('online') || 'Online', key: 'online', width: 90 }, { title: t('pages.clients.attachedInbounds') || 'Attached inbounds', key: 'inboundIds' }, { title: t('pages.inbounds.traffic') || 'Traffic', key: 'traffic' }, - { title: t('pages.inbounds.client.expiryTime') || 'Expiry', key: 'expiryTime' }, - { title: t('enable'), key: 'enable', width: 90 }, - { title: t('actions') || 'Actions', key: 'actions', width: 160 }, + { title: t('remained') || 'Remaining', key: 'remaining', width: 130 }, + { title: t('pages.inbounds.expireDate') || 'Expiry', key: 'expiryTime' }, + { title: t('enable') || 'Enable', key: 'enable', width: 90 }, + { title: t('actions') || 'Actions', key: 'actions', width: 220 }, ]); @@ -127,19 +210,39 @@ const columns = computed(() => [ - + @@ -214,4 +343,23 @@ const columns = computed(() => [ .loading-spacer { min-height: calc(100vh - 120px); } + +.email-cell { + display: flex; + flex-direction: column; +} + +.email { + font-weight: 500; +} + +.sub { + font-size: 11px; + opacity: 0.55; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 220px; +} diff --git a/frontend/src/pages/clients/useClients.js b/frontend/src/pages/clients/useClients.js index 5cd62ad6..84227d73 100644 --- a/frontend/src/pages/clients/useClients.js +++ b/frontend/src/pages/clients/useClients.js @@ -1,13 +1,16 @@ -import { onMounted, ref, shallowRef } from 'vue'; +import { onMounted, onUnmounted, ref, shallowRef } from 'vue'; import { HttpUtil } from '@/utils'; const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } }; +const ONLINES_POLL_MS = 10000; export function useClients() { const clients = shallowRef([]); const inbounds = shallowRef([]); + const onlines = ref([]); const loading = ref(false); const fetched = ref(false); + let onlinesTimer = null; async function refresh() { loading.value = true; @@ -28,6 +31,13 @@ export function useClients() { } } + async function refreshOnlines() { + const msg = await HttpUtil.post('/panel/api/inbounds/onlines'); + if (msg?.success) { + onlines.value = Array.isArray(msg.obj) ? msg.obj : []; + } + } + async function create(payload) { const msg = await HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS); if (msg?.success) await refresh(); @@ -61,18 +71,38 @@ export function useClients() { return msg; } - onMounted(refresh); + async function resetTraffic(client) { + const ibIds = Array.isArray(client?.inboundIds) ? client.inboundIds : []; + if (!client?.email || ibIds.length === 0) return null; + const url = `/panel/api/inbounds/${ibIds[0]}/resetClientTraffic/${encodeURIComponent(client.email)}`; + const msg = await HttpUtil.post(url); + if (msg?.success) await refresh(); + return msg; + } + + onMounted(async () => { + await refresh(); + refreshOnlines(); + onlinesTimer = setInterval(refreshOnlines, ONLINES_POLL_MS); + }); + + onUnmounted(() => { + if (onlinesTimer) clearInterval(onlinesTimer); + }); return { clients, inbounds, + onlines, loading, fetched, refresh, + refreshOnlines, create, update, remove, attach, detach, + resetTraffic, }; }