3x-ui/frontend/src/pages/clients/ClientInfoModal.vue

412 lines
12 KiB
Vue
Raw Normal View History

<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { CopyOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import { SizeFormatter, IntlUtil, ClipboardManager, HttpUtil } from '@/utils';
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 },
subSettings: {
type: Object,
default: () => ({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false }),
},
});
const emit = defineEmits(['update:open']);
const links = ref([]);
const linksLoading = ref(false);
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;
});
const subLink = computed(() => {
if (!props.client?.subId || !props.subSettings?.subURI) return '';
return props.subSettings.subURI + props.client.subId;
});
const subJsonLink = computed(() => {
if (!props.client?.subId) return '';
if (!props.subSettings?.subJsonEnable || !props.subSettings?.subJsonURI) return '';
return props.subSettings.subJsonURI + props.client.subId;
});
const showSubscription = computed(
() => !!(props.subSettings?.enable && props.client?.subId),
);
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);
}
function dateLabel(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'));
}
async function loadLinks() {
if (!props.client?.subId) {
links.value = [];
return;
}
linksLoading.value = true;
try {
const msg = await HttpUtil.get(
refactor(api): move every client-shaped endpoint off /inbounds onto /clients After the multi-inbound client migration, client state belongs to the client API surface, not the inbound one. Twelve routes that were crammed under /panel/api/inbounds/* now live where they belong, under /panel/api/clients/*. Moved (route, handler, doc): POST /clientIps/:email POST /clearClientIps/:email POST /onlines POST /lastOnline POST /updateClientTraffic/:email POST /resetAllClientTraffics/:id POST /delDepletedClients/:id POST /:id/resetClientTraffic/:email GET /getClientTraffics/:email GET /getClientTrafficsById/:id GET /getSubLinks/:subId GET /getClientLinks/:id/:email Their /clients/* counterparts are: POST /clients/clientIps/:email POST /clients/clearClientIps/:email POST /clients/onlines POST /clients/lastOnline POST /clients/updateTraffic/:email POST /clients/resetTraffic/:email (email-only, fans out) GET /clients/traffic/:email GET /clients/traffic/byId/:id GET /clients/subLinks/:subId GET /clients/links/:id/:email per-inbound resetAllClientTraffics and delDepletedClients are dropped entirely — the Clients page already exposes global Reset All Traffic and Delete depleted actions, and per-inbound resets are meaningless once a client can be attached to many inbounds. ClientService.ResetTrafficByEmail is the new email-only reset path: it looks up every inbound the client is attached to and pushes the counter reset + Xray re-add through inboundService.ResetClientTraffic for each one, so depleted users come back online instantly. Frontend callers (ClientsPage, useClients, ClientQrModal, ClientInfoModal, InboundInfoModal, InboundsPage, useInbounds) all switched to the new paths. The Inbounds page drops its per-inbound "Reset client traffic" and "Delete depleted clients" dropdown items — users do those at the client level now. api-docs is rebuilt to match. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 08:15:01 +00:00
`/panel/api/clients/subLinks/${encodeURIComponent(props.client.subId)}`,
);
links.value = msg?.success && Array.isArray(msg.obj) ? msg.obj : [];
} finally {
linksLoading.value = false;
}
}
watch(() => props.open, (next) => {
if (next) loadLinks();
else links.value = [];
});
function close() {
emit('update:open', false);
}
</script>
<template>
<a-modal :open="open" :title="client ? client.email : t('info')" :footer="null" :width="640" @cancel="close">
<template v-if="client">
<table class="info-table block">
<tbody>
<tr>
<td>{{ t('pages.clients.online') || 'Online' }}</td>
<td>
<a-tag v-if="client.enable && isOnline" color="green">{{ t('pages.clients.online') || 'Online' }}</a-tag>
<a-tag v-else>{{ t('pages.clients.offline') || 'Offline' }}</a-tag>
<span class="hint">{{ t('lastOnline') }}: {{ lastOnlineLabel(traffic?.lastOnline) }}</span>
</td>
</tr>
<tr>
<td>{{ t('status') }}</td>
<td>
<a-tag :color="client.enable ? 'green' : 'default'">
{{ client.enable ? t('enabled') : t('disabled') }}
</a-tag>
</td>
</tr>
<tr>
<td>{{ t('pages.clients.email') || 'Email' }}</td>
<td>
<a-tag v-if="client.email" color="green">{{ client.email }}</a-tag>
<a-tag v-else color="red">{{ t('none') }}</a-tag>
</td>
</tr>
<tr>
<td>subId</td>
<td>
<a-tag class="info-large-tag">{{ client.subId || '-' }}</a-tag>
<a-button v-if="client.subId" size="small" type="text" @click="copyValue(client.subId)">
<CopyOutlined />
</a-button>
</td>
</tr>
<tr v-if="client.uuid">
<td>UUID</td>
<td>
<a-tag class="info-large-tag">{{ client.uuid }}</a-tag>
<a-button size="small" type="text" @click="copyValue(client.uuid)">
<CopyOutlined />
</a-button>
</td>
</tr>
<tr v-if="client.password">
<td>{{ t('password') }}</td>
<td>
<a-tag class="info-large-tag">{{ client.password }}</a-tag>
<a-button size="small" type="text" @click="copyValue(client.password)">
<CopyOutlined />
</a-button>
</td>
</tr>
<tr v-if="client.auth">
<td>Auth</td>
<td>
<a-tag class="info-large-tag">{{ client.auth }}</a-tag>
<a-button size="small" type="text" @click="copyValue(client.auth)">
<CopyOutlined />
</a-button>
</td>
</tr>
<tr>
<td>Flow</td>
<td>
<a-tag v-if="client.flow">{{ client.flow }}</a-tag>
<a-tag v-else color="orange">{{ t('none') }}</a-tag>
</td>
</tr>
<tr>
<td>{{ t('pages.inbounds.traffic') }}</td>
<td>
<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>
</td>
</tr>
<tr>
<td>{{ t('remained') || 'Remaining' }}</td>
<td>
<a-tag v-if="remaining < 0" color="purple"></a-tag>
<a-tag v-else :color="remaining > 0 ? '' : 'red'">
{{ SizeFormatter.sizeFormat(remaining) }}
</a-tag>
</td>
</tr>
<tr>
<td>{{ t('pages.inbounds.expireDate') || 'Expiry' }}</td>
<td>
<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>
</td>
</tr>
<tr>
<td>IP limit</td>
<td>
<a-tag v-if="!client.limitIp"></a-tag>
<a-tag v-else>{{ client.limitIp }}</a-tag>
</td>
</tr>
<tr>
<td>{{ t('pages.inbounds.createdAt') || 'Created' }}</td>
<td>
<a-tag>{{ dateLabel(client.createdAt) }}</a-tag>
</td>
</tr>
<tr>
<td>{{ t('pages.inbounds.updatedAt') || 'Updated' }}</td>
<td>
<a-tag>{{ dateLabel(client.updatedAt) }}</a-tag>
</td>
</tr>
<tr v-if="client.comment">
<td>{{ t('pages.clients.comment') || 'Comment' }}</td>
<td>
<a-tag class="info-large-tag">{{ client.comment }}</a-tag>
</td>
</tr>
<tr>
<td>{{ t('pages.clients.attachedInbounds') || 'Attached inbounds' }}</td>
<td>
<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>
</td>
</tr>
</tbody>
</table>
<template v-if="links.length > 0">
<a-divider>{{ t('pages.inbounds.copyLink') || 'URL' }}</a-divider>
<div v-for="(link, idx) in links" :key="idx" class="link-panel">
<div class="link-panel-header">
<a-tag color="green">{{ `${t('pages.clients.link') || 'Link'} ${idx + 1}` }}</a-tag>
<a-tooltip :title="t('copy')">
<a-button size="small" @click="copyValue(link)">
<template #icon>
<CopyOutlined />
</template>
</a-button>
</a-tooltip>
</div>
<code class="link-panel-text">{{ link }}</code>
</div>
</template>
<template v-if="showSubscription && subLink">
<a-divider>{{ t('subscription.title') || 'Subscription info' }}</a-divider>
<div class="link-panel">
<div class="link-panel-header">
<a-tag color="green">{{ t('subscription.title') || 'Subscription info' }}</a-tag>
<a-tooltip :title="t('copy')">
<a-button size="small" @click="copyValue(subLink)">
<template #icon>
<CopyOutlined />
</template>
</a-button>
</a-tooltip>
</div>
<a :href="subLink" target="_blank" rel="noopener noreferrer" class="link-panel-anchor">{{ subLink }}</a>
</div>
<div v-if="subJsonLink" class="link-panel">
<div class="link-panel-header">
<a-tag color="green">JSON</a-tag>
<a-tooltip :title="t('copy')">
<a-button size="small" @click="copyValue(subJsonLink)">
<template #icon>
<CopyOutlined />
</template>
</a-button>
</a-tooltip>
</div>
<a :href="subJsonLink" target="_blank" rel="noopener noreferrer" class="link-panel-anchor">{{ subJsonLink }}</a>
</div>
</template>
</template>
</a-modal>
</template>
<style scoped>
.info-table {
width: 100%;
border-collapse: collapse;
}
.info-table.block {
margin-bottom: 10px;
}
.info-table td {
padding: 4px 8px;
vertical-align: top;
}
.info-table td:first-child {
width: 140px;
font-size: 13px;
opacity: 0.75;
white-space: nowrap;
}
.info-large-tag {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
}
.hint {
font-size: 12px;
opacity: 0.55;
margin-left: 6px;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.link-panel {
border: 1px solid rgba(128, 128, 128, 0.2);
border-radius: 8px;
padding: 10px;
margin-bottom: 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.link-panel-header {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.link-panel-text {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
word-break: break-all;
white-space: pre-wrap;
padding: 6px 8px;
background: rgba(0, 0, 0, 0.04);
border-radius: 4px;
user-select: all;
}
:global(body.dark) .link-panel-text {
background: rgba(255, 255, 255, 0.05);
}
.link-panel-anchor {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
word-break: break-all;
padding: 6px 8px;
background: rgba(0, 0, 0, 0.04);
border-radius: 4px;
color: var(--ant-color-primary, #1677ff);
text-decoration: underline;
text-decoration-color: rgba(22, 119, 255, 0.4);
transition: background 120ms ease, text-decoration-color 120ms ease;
}
.link-panel-anchor:hover {
background: rgba(22, 119, 255, 0.08);
text-decoration-color: var(--ant-color-primary, #1677ff);
}
:global(body.dark) .link-panel-anchor {
background: rgba(255, 255, 255, 0.05);
}
:global(body.dark) .link-panel-anchor:hover {
background: rgba(22, 119, 255, 0.16);
}
</style>