mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 12:44:22 +00:00
feat(clients): add Reset Traffic, QR Code, Info actions + Online/Remaining columns
The Clients page table gains: - Online column — green/grey tag driven by /panel/api/inbounds/onlines, polled every 10s. - Remaining column — bytes-remaining tag, coloured green/orange/red against quota, purple infinity when unlimited. - Action icons per row: QR, Info, Reset traffic, Edit, Delete. ClientInfoModal shows the full client detail (uuid/password/auth, traffic ↑/↓ + remaining + all-time, expiry absolute + relative, attached inbounds chip list, online + last-online). ClientQrModal fetches links for the client's subId via /panel/api/inbounds/getSubLinks/:subId and renders each one through the existing QrPanel component. Reset Traffic confirms then calls the existing per-inbound endpoint on the client's first attached inbound (the traffic row is keyed on email globally, so any attached inbound resets the shared counter). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
62fd9f9d82
commit
bb5296aa0e
4 changed files with 464 additions and 16 deletions
206
frontend/src/pages/clients/ClientInfoModal.vue
Normal file
206
frontend/src/pages/clients/ClientInfoModal.vue
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { SizeFormatter, IntlUtil, ClipboardManager } from '@/utils';
|
||||
import { CopyOutlined } from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
client: { type: Object, default: null },
|
||||
inboundsById: { type: Object, default: () => ({}) },
|
||||
isOnline: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open']);
|
||||
|
||||
const traffic = computed(() => props.client?.traffic || null);
|
||||
const totalBytes = computed(() => props.client?.totalGB || 0);
|
||||
const used = computed(() => (traffic.value?.up || 0) + (traffic.value?.down || 0));
|
||||
const remaining = computed(() => {
|
||||
if (totalBytes.value <= 0) return -1;
|
||||
const r = totalBytes.value - used.value;
|
||||
return r > 0 ? r : 0;
|
||||
});
|
||||
|
||||
function expiryLabel(ts) {
|
||||
if (!ts || ts <= 0) return '∞';
|
||||
return IntlUtil.formatDate(ts);
|
||||
}
|
||||
|
||||
function expiryRelative(ts) {
|
||||
if (!ts || ts <= 0) return '';
|
||||
return IntlUtil.formatRelativeTime(ts);
|
||||
}
|
||||
|
||||
function lastOnlineLabel(ts) {
|
||||
if (!ts || ts <= 0) return '-';
|
||||
return IntlUtil.formatDate(ts);
|
||||
}
|
||||
|
||||
async function copyValue(text) {
|
||||
if (!text) return;
|
||||
const ok = await ClipboardManager.copyText(String(text));
|
||||
if (ok) message.success(t('copied'));
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="client ? client.email : t('info')" :footer="null" :width="560"
|
||||
@cancel="close">
|
||||
<div v-if="client" class="info-grid">
|
||||
<div class="row">
|
||||
<span class="label">{{ t('online') }}</span>
|
||||
<a-tag v-if="client.enable && isOnline" color="green">{{ t('online') }}</a-tag>
|
||||
<a-tag v-else>{{ t('offline') }}</a-tag>
|
||||
<span class="hint">{{ t('lastOnline') }}: {{ lastOnlineLabel(traffic?.lastOnline) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<span class="label">{{ t('enable') }}</span>
|
||||
<a-tag :color="client.enable ? 'green' : 'default'">
|
||||
{{ client.enable ? t('enable') : t('disable') }}
|
||||
</a-tag>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<span class="label">subId</span>
|
||||
<span class="value mono">{{ client.subId || '-' }}</span>
|
||||
<a-button v-if="client.subId" size="small" type="text" @click="copyValue(client.subId)">
|
||||
<CopyOutlined />
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<div v-if="client.uuid" class="row">
|
||||
<span class="label">UUID</span>
|
||||
<span class="value mono">{{ client.uuid }}</span>
|
||||
<a-button size="small" type="text" @click="copyValue(client.uuid)">
|
||||
<CopyOutlined />
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<div v-if="client.password" class="row">
|
||||
<span class="label">Password</span>
|
||||
<span class="value mono">{{ client.password }}</span>
|
||||
<a-button size="small" type="text" @click="copyValue(client.password)">
|
||||
<CopyOutlined />
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<div v-if="client.auth" class="row">
|
||||
<span class="label">Auth</span>
|
||||
<span class="value mono">{{ client.auth }}</span>
|
||||
<a-button size="small" type="text" @click="copyValue(client.auth)">
|
||||
<CopyOutlined />
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<span class="label">{{ t('pages.inbounds.traffic') }}</span>
|
||||
<a-tag>
|
||||
↑ {{ SizeFormatter.sizeFormat(traffic?.up || 0) }}
|
||||
/ ↓ {{ SizeFormatter.sizeFormat(traffic?.down || 0) }}
|
||||
</a-tag>
|
||||
<span class="hint">
|
||||
{{ SizeFormatter.sizeFormat(used) }}
|
||||
/
|
||||
{{ totalBytes > 0 ? SizeFormatter.sizeFormat(totalBytes) : '∞' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<span class="label">{{ t('remained') || 'Remaining' }}</span>
|
||||
<a-tag v-if="remaining < 0" color="purple">∞</a-tag>
|
||||
<a-tag v-else :color="remaining > 0 ? '' : 'red'">
|
||||
{{ SizeFormatter.sizeFormat(remaining) }}
|
||||
</a-tag>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<span class="label">{{ t('pages.inbounds.allTimeTraffic') || 'All-time' }}</span>
|
||||
<a-tag>{{ SizeFormatter.sizeFormat(traffic?.allTime || (used)) }}</a-tag>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<span class="label">{{ t('pages.inbounds.expireDate') || 'Expiry' }}</span>
|
||||
<a-tag v-if="!client.expiryTime || client.expiryTime <= 0" color="purple">∞</a-tag>
|
||||
<a-tag v-else>{{ expiryLabel(client.expiryTime) }}</a-tag>
|
||||
<span v-if="client.expiryTime > 0" class="hint">{{ expiryRelative(client.expiryTime) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<span class="label">IP limit</span>
|
||||
<a-tag v-if="!client.limitIp">∞</a-tag>
|
||||
<a-tag v-else>{{ client.limitIp }}</a-tag>
|
||||
</div>
|
||||
|
||||
<div v-if="client.comment" class="row">
|
||||
<span class="label">{{ t('pages.inbounds.client.comment') || 'Comment' }}</span>
|
||||
<span class="value">{{ client.comment }}</span>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<span class="label">{{ t('pages.clients.attachedInbounds') || 'Attached inbounds' }}</span>
|
||||
<div class="chips">
|
||||
<a-tag v-for="id in (client.inboundIds || [])" :key="id" color="blue">
|
||||
<template v-if="inboundsById[id]">
|
||||
{{ inboundsById[id].remark || `#${id}` }} ({{ inboundsById[id].protocol }}:{{ inboundsById[id].port }})
|
||||
</template>
|
||||
<template v-else>#{{ id }}</template>
|
||||
</a-tag>
|
||||
<span v-if="!client.inboundIds || client.inboundIds.length === 0" class="hint">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.info-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.label {
|
||||
min-width: 120px;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.value {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
</style>
|
||||
64
frontend/src/pages/clients/ClientQrModal.vue
Normal file
64
frontend/src/pages/clients/ClientQrModal.vue
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import QrPanel from '@/pages/inbounds/QrPanel.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
client: { type: Object, default: null },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open']);
|
||||
|
||||
const links = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
watch(() => props.open, async (next) => {
|
||||
if (!next || !props.client?.subId) {
|
||||
links.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.get(`/panel/api/inbounds/getSubLinks/${encodeURIComponent(props.client.subId)}`);
|
||||
links.value = msg?.success && Array.isArray(msg.obj) ? msg.obj : [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="client ? client.email : t('qrCode')" :footer="null" :width="520" centered
|
||||
@cancel="close">
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="!client?.subId && !loading" class="empty">
|
||||
{{ t('pages.clients.noSubId') || 'This client has no subId, no shareable link.' }}
|
||||
</div>
|
||||
<div v-else-if="links.length === 0 && !loading" class="empty">
|
||||
{{ t('pages.clients.noLinks') || 'No shareable links — attach this client to a protocol-capable inbound first.' }}
|
||||
</div>
|
||||
<a-collapse v-else :default-active-key="['l0']" accordion>
|
||||
<a-collapse-panel v-for="(link, idx) in links" :key="`l${idx}`"
|
||||
:header="`${t('pages.clients.link') || 'Link'} ${idx + 1}`">
|
||||
<QrPanel :value="link" :remark="`${client?.email || ''} #${idx + 1}`" />
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</a-spin>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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 },
|
||||
]);
|
||||
</script>
|
||||
|
||||
|
|
@ -127,19 +210,39 @@ const columns = computed(() => [
|
|||
</a-button>
|
||||
</template>
|
||||
|
||||
<a-table :columns="columns" :data-source="clients" :loading="loading" row-key="id" :pagination="{ pageSize: 20, showSizeChanger: true, pageSizeOptions: ['10','20','50','100'] }" size="small">
|
||||
<a-table :columns="columns" :data-source="clients" :loading="loading" row-key="id"
|
||||
:pagination="{ pageSize: 20, showSizeChanger: true, pageSizeOptions: ['10','20','50','100'] }"
|
||||
size="small">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'inboundIds'">
|
||||
<template v-if="column.key === 'email'">
|
||||
<div class="email-cell">
|
||||
<span class="email">{{ record.email }}</span>
|
||||
<span v-if="record.subId" class="sub" :title="record.subId">{{ record.subId }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'online'">
|
||||
<a-tag v-if="record.enable && isOnline(record.email)" color="green">{{ t('online') || 'Online' }}</a-tag>
|
||||
<a-tag v-else>{{ t('offline') || 'Offline' }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'inboundIds'">
|
||||
<a-tag v-for="id in record.inboundIds" :key="id" color="blue" style="margin: 2px">
|
||||
{{ inboundLabel(id) }}
|
||||
</a-tag>
|
||||
<span v-if="!record.inboundIds || record.inboundIds.length === 0" style="color: rgba(0,0,0,0.45)">—</span>
|
||||
<span v-if="!record.inboundIds || record.inboundIds.length === 0"
|
||||
style="color: rgba(0,0,0,0.45)">—</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'traffic'">
|
||||
{{ trafficLabel(record) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'remaining'">
|
||||
<a-tag :color="remainingColor(record)">{{ remainingLabel(record) }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'expiryTime'">
|
||||
{{ expiryLabel(record) }}
|
||||
<a-tooltip :title="expiryLabel(record)">
|
||||
<a-tag :color="expiryColor(record)">
|
||||
{{ record.expiryTime > 0 ? expiryRelative(record) : '∞' }}
|
||||
</a-tag>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'enable'">
|
||||
<a-tag :color="record.enable ? 'green' : 'default'">
|
||||
|
|
@ -147,9 +250,32 @@ const columns = computed(() => [
|
|||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-button size="small" @click="onEdit(record)">{{ t('edit') }}</a-button>
|
||||
<a-button size="small" danger @click="onDelete(record)">{{ t('delete') }}</a-button>
|
||||
<a-space :size="4">
|
||||
<a-tooltip :title="t('qrCode') || 'QR Code'">
|
||||
<a-button size="small" type="text" @click="onShowQr(record)">
|
||||
<QrcodeOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip :title="t('info') || 'Info'">
|
||||
<a-button size="small" type="text" @click="onShowInfo(record)">
|
||||
<InfoCircleOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip :title="t('pages.inbounds.resetTraffic') || 'Reset traffic'">
|
||||
<a-button size="small" type="text" @click="onResetTraffic(record)">
|
||||
<RetweetOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip :title="t('edit')">
|
||||
<a-button size="small" type="text" @click="onEdit(record)">
|
||||
<EditOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip :title="t('delete')">
|
||||
<a-button size="small" type="text" danger @click="onDelete(record)">
|
||||
<DeleteOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
|
|
@ -170,6 +296,9 @@ const columns = computed(() => [
|
|||
|
||||
<ClientFormModal v-model:open="formOpen" :mode="formMode" :client="editingClient"
|
||||
:attached-ids="editingAttachedIds" :inbounds="inbounds" :save="onSave" />
|
||||
<ClientInfoModal v-model:open="infoOpen" :client="infoClient" :inbounds-by-id="inboundsById"
|
||||
:is-online="infoClient ? isOnline(infoClient.email) : false" />
|
||||
<ClientQrModal v-model:open="qrOpen" :client="qrClient" />
|
||||
</a-layout>
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
|
@ -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;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue