3x-ui/frontend/src/pages/clients/ClientInfoModal.vue
MHSanaei b57cafed94
i18n(clients): replace English fallbacks with proper translation keys
Pulls every hard-coded English label/title in the Clients page and its
four modals through the i18n layer so localized panels stop leaking
English. New keys live under pages.clients (auth, hysteriaAuth, uuid,
flow, flowNone, reverseTag, reverseTagPlaceholder, telegramId,
telegramIdPlaceholder, created, updated, ipLimit) plus refresh at the
root and toasts.bulkDeletedMixed / bulkCreatedMixed for partial-failure
toasts. Also switches the add-client modal's primary button from "Add"
to "Create" for consistency with other create flows.

The bulk-add Random/Random+Prefix/... email-method options stay
hard-coded by request - they're identifier-shaped strings.
2026-05-18 10:17:15 +02:00

411 lines
12 KiB
Vue

<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(
`/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') }}</td>
<td>
<a-tag v-if="client.enable && isOnline" color="green">{{ t('pages.clients.online') }}</a-tag>
<a-tag v-else>{{ t('pages.clients.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') }}</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>{{ t('pages.clients.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>{{ t('pages.clients.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>{{ t('pages.clients.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>{{ t('pages.clients.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') }}</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') }}</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>{{ t('pages.clients.ipLimit') }}</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') }}</td>
<td>
<a-tag>{{ dateLabel(client.createdAt) }}</a-tag>
</td>
</tr>
<tr>
<td>{{ t('pages.inbounds.updatedAt') }}</td>
<td>
<a-tag>{{ dateLabel(client.updatedAt) }}</a-tag>
</td>
</tr>
<tr v-if="client.comment">
<td>{{ t('pages.clients.comment') }}</td>
<td>
<a-tag class="info-large-tag">{{ client.comment }}</a-tag>
</td>
</tr>
<tr>
<td>{{ t('pages.clients.attachedInbounds') }}</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') }}</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')} ${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') }}</a-divider>
<div class="link-panel">
<div class="link-panel-header">
<a-tag color="green">{{ t('subscription.title') }}</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>