3x-ui/frontend/src/pages/inbounds/InboundList.vue
MHSanaei e7d117f11f
i18n(frontend): translate page chrome — sidebar, save bars, tabs, summary cards
Replaces hardcoded English with t() calls in the components every
user sees on every page load. The translations themselves come from
the existing TOML files via the sync script — no new strings, no
new locale keys.

Per component:
- AppSidebar.vue: 5 menu titles (dashboard / inbounds / settings /
  xray / logout). Computed so the sidebar re-renders when the
  cookie-driven locale flips on reload.
- IndexPage.vue: Quick actions card title + Logs / Backup / Up-to-
  date / Update buttons.
- StatusCard.vue: CPU / Memory / Swap / Storage labels +
  logical-processors / frequency tooltips.
- XrayStatusCard.vue: card title + error popover header + Stop /
  Restart / Switch xray action labels (kept the v-prefix version
  string as-is — it's content, not a label).
- SettingsPage.vue: 5 tab titles + Save / Restart-panel buttons +
  unsaved-changes warning.
- XrayPage.vue: 6 tab titles + Save / Restart-xray buttons +
  unsaved-changes warning.
- InboundsPage.vue: 5 summary-stat card titles.
- InboundList.vue: 10 column titles (computed for live locale),
  Add inbound / General actions buttons + every dropdown menu item,
  search placeholder, filter radio labels, popover titles
  (disabled / depleted / depleting / online), traffic + info
  popover row labels.

Total: ~75 strings localised across 8 files. The remaining English
labels live in the per-tab settings forms, the form modals
(Inbound / Client / Outbound / Rule / Balancer / WARP / Nord), and
the per-row table cell helpers — all incremental work that doesn't
touch infrastructure.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:07:41 +02:00

563 lines
23 KiB
Vue

<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import {
PlusOutlined,
MenuOutlined,
SyncOutlined,
DownOutlined,
SearchOutlined,
FilterOutlined,
MoreOutlined,
EditOutlined,
QrcodeOutlined,
UserAddOutlined,
UsergroupAddOutlined,
CopyOutlined,
FileDoneOutlined,
ExportOutlined,
ImportOutlined,
ReloadOutlined,
RestOutlined,
RetweetOutlined,
BlockOutlined,
DeleteOutlined,
InfoCircleOutlined,
} from '@ant-design/icons-vue';
import { HttpUtil, ObjectUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
import { DBInbound } from '@/models/dbinbound.js';
import { Inbound } from '@/models/inbound.js';
import ClientRowTable from './ClientRowTable.vue';
const { t } = useI18n();
const props = defineProps({
dbInbounds: { type: Array, required: true },
clientCount: { type: Object, required: true },
onlineClients: { type: Array, required: true },
lastOnlineMap: { type: Object, default: () => ({}) },
refreshing: { type: Boolean, default: false },
expireDiff: { type: Number, default: 0 },
trafficDiff: { type: Number, default: 0 },
pageSize: { type: Number, default: 0 },
isMobile: { type: Boolean, default: false },
isDarkTheme: { type: Boolean, default: false },
subEnable: { type: Boolean, default: false },
});
const emit = defineEmits([
'refresh',
'add-inbound',
'general-action',
'row-action',
// Per-client events surfaced from the expand-row table.
'edit-client',
'qrcode-client',
'info-client',
'reset-traffic-client',
'delete-client',
'toggle-enable-client',
]);
// ============ Toolbar / search & filter =============================
const enableFilter = ref(false);
const searchKey = ref('');
const filterBy = ref('');
// Auto-refresh — same defaults as legacy (5s, opt-in via switch).
const isRefreshEnabled = ref(localStorage.getItem('isRefreshEnabled') === 'true');
const refreshIntervalMs = ref(Number(localStorage.getItem('refreshInterval')) || 5000);
let timer = null;
function startAutoRefresh() {
stopAutoRefresh();
timer = setInterval(() => emit('refresh'), refreshIntervalMs.value);
}
function stopAutoRefresh() {
if (timer != null) {
clearInterval(timer);
timer = null;
}
}
watch(isRefreshEnabled, (next) => {
localStorage.setItem('isRefreshEnabled', String(next));
if (next) startAutoRefresh();
else stopAutoRefresh();
}, { immediate: true });
watch(refreshIntervalMs, (next) => {
localStorage.setItem('refreshInterval', String(next));
if (isRefreshEnabled.value) startAutoRefresh();
});
// Toggle the filter mode — flip cleans the other input.
function onToggleFilter() {
if (enableFilter.value) searchKey.value = '';
else filterBy.value = '';
}
// ============ Search / filter projection =============================
// Mirrors the legacy logic: when searching, keep inbounds that match
// anywhere (deep search); when filtering, keep inbounds that have at
// least one client in the requested bucket and reduce their settings
// to that bucket.
function projectInbound(dbInbound, predicate) {
const next = new DBInbound(dbInbound);
let settings = {};
try {
settings = JSON.parse(dbInbound.settings || '{}');
} catch (_e) {
settings = {};
}
if (!Array.isArray(settings.clients)) return next;
const filtered = settings.clients.filter(predicate);
next.settings = Inbound.Settings.fromJson(dbInbound.protocol, { clients: filtered });
next.invalidateCache();
return next;
}
const visibleInbounds = computed(() => {
if (enableFilter.value) {
if (ObjectUtil.isEmpty(filterBy.value)) return [...props.dbInbounds];
const out = [];
for (const dbInbound of props.dbInbounds) {
const c = props.clientCount[dbInbound.id];
if (!c || !c[filterBy.value] || c[filterBy.value].length === 0) continue;
const list = c[filterBy.value];
out.push(projectInbound(dbInbound, (client) => list.includes(client.email)));
}
return out;
}
if (ObjectUtil.isEmpty(searchKey.value)) return [...props.dbInbounds];
const out = [];
for (const dbInbound of props.dbInbounds) {
if (!ObjectUtil.deepSearch(dbInbound, searchKey.value)) continue;
out.push(projectInbound(dbInbound, (client) => ObjectUtil.deepSearch(client, searchKey.value)));
}
return out;
});
// ============ Columns =================================================
// `key`-driven so we can render via the body-cell slot below. AD-Vue 4's
// `responsive` array still works on column defs. Computed so column
// labels react to live locale switches.
const desktopColumns = computed(() => [
{ title: 'ID', dataIndex: 'id', key: 'id', align: 'right', width: 30, responsive: ['xs'] },
{ title: t('pages.inbounds.operate'), key: 'action', align: 'center', width: 30 },
{ title: t('pages.inbounds.enable'), key: 'enable', align: 'center', width: 35 },
{ title: t('pages.inbounds.remark'), dataIndex: 'remark', key: 'remark', align: 'center', width: 60 },
{ title: t('pages.inbounds.port'), dataIndex: 'port', key: 'port', align: 'center', width: 40 },
{ title: t('pages.inbounds.protocol'), key: 'protocol', align: 'left', width: 70 },
{ title: t('clients'), key: 'clients', align: 'left', width: 50 },
{ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'center', width: 90 },
{ title: t('pages.inbounds.allTimeTraffic'), key: 'allTimeInbound', align: 'center', width: 60 },
{ title: t('pages.inbounds.expireDate'), key: 'expiryTime', align: 'center', width: 40 },
]);
const mobileColumns = computed(() => [
{ title: 'ID', dataIndex: 'id', key: 'id', align: 'right', width: 10, responsive: ['s'] },
{ title: t('pages.inbounds.operate'), key: 'action', align: 'center', width: 25 },
{ title: t('pages.inbounds.remark'), dataIndex: 'remark', key: 'remark', align: 'left', width: 70 },
{ title: t('info'), key: 'info', align: 'center', width: 10 },
]);
const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopColumns.value));
// ============ Pagination ============================================
function paginationFor(rows) {
const size = props.pageSize > 0 ? props.pageSize : rows.length || 1;
return {
pageSize: size,
showSizeChanger: false,
hideOnSinglePage: true,
};
}
// ============ Per-row enable switch =================================
async function onSwitchEnable(dbInbound, next) {
const previous = dbInbound.enable;
dbInbound.enable = next; // optimistic
try {
const formData = new FormData();
formData.append('enable', String(next));
const msg = await HttpUtil.post(`/panel/api/inbounds/setEnable/${dbInbound.id}`, formData);
if (!msg?.success) dbInbound.enable = previous;
} catch (_e) {
dbInbound.enable = previous;
}
}
// ============ Helpers shared with the templates =====================
function isClientOnline(email) {
return props.onlineClients.includes(email);
}
// Whether to show the "Switch xray" / qrcode menu entry — same predicate
// as legacy: SS single-user inbounds and WireGuard inbounds expose
// inbound-wide QR codes.
function showQrCodeMenu(dbInbound) {
if (dbInbound.isWireguard) return true;
if (dbInbound.isSS) {
try {
return !dbInbound.toInbound().isSSMultiUser;
} catch (_e) {
return false;
}
}
return false;
}
</script>
<template>
<a-card hoverable>
<template #title>
<a-space direction="horizontal">
<a-button type="primary" @click="emit('add-inbound')">
<template #icon><PlusOutlined /></template>
<template v-if="!isMobile">{{ t('pages.inbounds.addInbound') }}</template>
</a-button>
<a-dropdown :trigger="['click']">
<a-button type="primary">
<template #icon><MenuOutlined /></template>
<template v-if="!isMobile">{{ t('pages.inbounds.generalActions') }}</template>
</a-button>
<template #overlay>
<a-menu @click="(a) => emit('general-action', a.key)">
<a-menu-item key="import">
<ImportOutlined /> {{ t('pages.inbounds.importInbound') }}
</a-menu-item>
<a-menu-item key="export">
<ExportOutlined /> {{ t('pages.inbounds.export') }}
</a-menu-item>
<a-menu-item v-if="subEnable" key="subs">
<ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
</a-menu-item>
<a-menu-item key="resetInbounds">
<ReloadOutlined /> {{ t('pages.inbounds.resetAllTraffic') }}
</a-menu-item>
<a-menu-item key="resetClients">
<FileDoneOutlined /> {{ t('pages.inbounds.resetAllClientTraffics') }}
</a-menu-item>
<a-menu-item key="delDepletedClients" class="danger-item">
<RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-space>
</template>
<template #extra>
<a-button-group>
<a-button :loading="refreshing" @click="emit('refresh')">
<template #icon><SyncOutlined /></template>
</a-button>
<a-popover placement="bottomRight" trigger="click">
<template #title>
<div class="auto-refresh-title">
<a-switch v-model:checked="isRefreshEnabled" size="small" />
<span>{{ t('pages.inbounds.autoRefresh') }}</span>
</div>
</template>
<template #content>
<a-space direction="vertical">
<span>{{ t('pages.inbounds.autoRefreshInterval') }}</span>
<a-select
v-model:value="refreshIntervalMs"
:disabled="!isRefreshEnabled"
:style="{ width: '100%' }"
>
<a-select-option v-for="key in [5, 10, 30, 60]" :key="key" :value="key * 1000">
{{ key }}s
</a-select-option>
</a-select>
</a-space>
</template>
<a-button>
<template #icon><DownOutlined /></template>
</a-button>
</a-popover>
</a-button-group>
</template>
<a-space direction="vertical" :style="{ width: '100%' }">
<!-- Search / filter toolbar -->
<div :class="isMobile ? 'filter-bar mobile' : 'filter-bar'">
<a-switch v-model:checked="enableFilter" @change="onToggleFilter">
<template #checkedChildren><SearchOutlined /></template>
<template #unCheckedChildren><FilterOutlined /></template>
</a-switch>
<a-input
v-if="!enableFilter"
v-model:value="searchKey"
:placeholder="t('search')"
autofocus
:size="isMobile ? 'small' : 'middle'"
:style="{ maxWidth: '300px' }"
/>
<a-radio-group
v-if="enableFilter"
v-model:value="filterBy"
button-style="solid"
:size="isMobile ? 'small' : 'middle'"
>
<a-radio-button value="">{{ t('none') }}</a-radio-button>
<a-radio-button value="active">{{ t('subscription.active') }}</a-radio-button>
<a-radio-button value="deactive">{{ t('disabled') }}</a-radio-button>
<a-radio-button value="depleted">{{ t('depleted') }}</a-radio-button>
<a-radio-button value="expiring">{{ t('depletingSoon') }}</a-radio-button>
<a-radio-button value="online">{{ t('online') }}</a-radio-button>
</a-radio-group>
</div>
<a-table
:columns="columns"
:data-source="visibleInbounds"
:row-key="(r) => r.id"
:pagination="paginationFor(visibleInbounds)"
:scroll="isMobile ? {} : { x: 1000 }"
:style="{ marginTop: '10px' }"
size="small"
:row-class-name="(r) => (r.isMultiUser() ? '' : 'hide-expand-icon')"
>
<!-- Per-inbound client list, expanded by clicking the row's
default expand chevron. Hidden via row-class-name for
non-multi-user inbounds (matches legacy behavior). -->
<template #expandedRowRender="{ record }">
<ClientRowTable
v-if="record.isMultiUser()"
:db-inbound="record"
:is-mobile="isMobile"
:traffic-diff="trafficDiff"
:expire-diff="expireDiff"
:online-clients="onlineClients"
:last-online-map="lastOnlineMap"
:is-dark-theme="isDarkTheme"
@edit-client="(p) => emit('edit-client', p)"
@qrcode-client="(p) => emit('qrcode-client', p)"
@info-client="(p) => emit('info-client', p)"
@reset-traffic-client="(p) => emit('reset-traffic-client', p)"
@delete-client="(p) => emit('delete-client', p)"
@toggle-enable-client="(p) => emit('toggle-enable-client', p)"
/>
</template>
<template #bodyCell="{ column, record }">
<!-- ============== Action dropdown ============== -->
<template v-if="column.key === 'action'">
<a-dropdown :trigger="['click']">
<MoreOutlined class="row-action-trigger" @click.prevent />
<template #overlay>
<a-menu @click="(a) => emit('row-action', { key: a.key, dbInbound: record })">
<a-menu-item key="edit"><EditOutlined /> {{ t('edit') }}</a-menu-item>
<a-menu-item v-if="showQrCodeMenu(record)" key="qrcode">
<QrcodeOutlined /> {{ t('qrCode') }}
</a-menu-item>
<template v-if="record.isMultiUser()">
<a-menu-item key="addClient"><UserAddOutlined /> {{ t('pages.client.add') }}</a-menu-item>
<a-menu-item key="addBulkClient"><UsergroupAddOutlined /> {{ t('pages.client.bulk') }}</a-menu-item>
<a-menu-item key="copyClients"><CopyOutlined /> {{ t('pages.client.copyFromInbound') }}</a-menu-item>
<a-menu-item key="resetClients"><FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}</a-menu-item>
<a-menu-item key="export"><ExportOutlined /> {{ t('pages.inbounds.export') }}</a-menu-item>
<a-menu-item v-if="subEnable" key="subs">
<ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
</a-menu-item>
<a-menu-item key="delDepletedClients" class="danger-item">
<RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
</a-menu-item>
</template>
<template v-else>
<a-menu-item key="showInfo"><InfoCircleOutlined /> {{ t('info') }}</a-menu-item>
</template>
<a-menu-item key="clipboard"><CopyOutlined /> {{ t('pages.inbounds.exportInbound') }}</a-menu-item>
<a-menu-item key="resetTraffic"><RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}</a-menu-item>
<a-menu-item key="clone"><BlockOutlined /> {{ t('pages.inbounds.clone') }}</a-menu-item>
<a-menu-item key="delete" class="danger-item">
<DeleteOutlined /> {{ t('delete') }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
<!-- ============== Enable switch (desktop) ============== -->
<template v-else-if="column.key === 'enable'">
<a-switch
:checked="record.enable"
@change="(next) => onSwitchEnable(record, next)"
/>
</template>
<!-- ============== Protocol tags ============== -->
<template v-else-if="column.key === 'protocol'">
<div class="protocol-tags">
<a-tag color="purple">{{ record.protocol }}</a-tag>
<template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS">
<a-tag color="green">{{ record.toInbound().stream.network }}</a-tag>
<a-tag v-if="record.toInbound().stream.isTls" color="blue">TLS</a-tag>
<a-tag v-if="record.toInbound().stream.isReality" color="blue">Reality</a-tag>
</template>
</div>
</template>
<!-- ============== Clients tag + popovers ============== -->
<template v-else-if="column.key === 'clients'">
<template v-if="clientCount[record.id]">
<a-tag color="green" style="margin: 0">{{ clientCount[record.id].clients }}</a-tag>
<a-popover v-if="clientCount[record.id].deactive.length" :title="t('disabled')">
<template #content>
<div v-for="email in clientCount[record.id].deactive" :key="email">{{ email }}</div>
</template>
<a-tag style="margin: 0; padding: 0 2px">{{ clientCount[record.id].deactive.length }}</a-tag>
</a-popover>
<a-popover v-if="clientCount[record.id].depleted.length" :title="t('depleted')">
<template #content>
<div v-for="email in clientCount[record.id].depleted" :key="email">{{ email }}</div>
</template>
<a-tag color="red" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].depleted.length }}</a-tag>
</a-popover>
<a-popover v-if="clientCount[record.id].expiring.length" :title="t('depletingSoon')">
<template #content>
<div v-for="email in clientCount[record.id].expiring" :key="email">{{ email }}</div>
</template>
<a-tag color="orange" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].expiring.length }}</a-tag>
</a-popover>
<a-popover v-if="clientCount[record.id].online.length" :title="t('online')">
<template #content>
<div v-for="email in clientCount[record.id].online" :key="email">{{ email }}</div>
</template>
<a-tag color="blue" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].online.length }}</a-tag>
</a-popover>
</template>
</template>
<!-- ============== Traffic ============== -->
<template v-else-if="column.key === 'traffic'">
<a-popover>
<template #content>
<table cellpadding="2">
<tbody>
<tr>
<td> {{ SizeFormatter.sizeFormat(record.up) }}</td>
<td> {{ SizeFormatter.sizeFormat(record.down) }}</td>
</tr>
<tr v-if="record.total > 0 && record.up + record.down < record.total">
<td>{{ t('remained') }}</td>
<td>{{ SizeFormatter.sizeFormat(record.total - record.up - record.down) }}</td>
</tr>
</tbody>
</table>
</template>
<a-tag :color="ColorUtils.usageColor(record.up + record.down, trafficDiff, record.total)">
{{ SizeFormatter.sizeFormat(record.up + record.down) }} /
<template v-if="record.total > 0">{{ SizeFormatter.sizeFormat(record.total) }}</template>
<template v-else></template>
</a-tag>
</a-popover>
</template>
<!-- ============== All-time inbound traffic ============== -->
<template v-else-if="column.key === 'allTimeInbound'">
<a-tag>{{ SizeFormatter.sizeFormat(record.allTime || 0) }}</a-tag>
</template>
<!-- ============== Expiry ============== -->
<template v-else-if="column.key === 'expiryTime'">
<a-popover v-if="record.expiryTime > 0">
<template #content>{{ IntlUtil.formatDate(record.expiryTime) }}</template>
<a-tag
:color="ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)"
style="min-width: 50px"
>
{{ IntlUtil.formatRelativeTime(record.expiryTime) }}
</a-tag>
</a-popover>
<a-tag v-else color="purple"></a-tag>
</template>
<!-- ============== Mobile info popover ============== -->
<template v-else-if="column.key === 'info'">
<a-popover placement="bottomRight" trigger="click">
<template #content>
<table cellpadding="2">
<tbody>
<tr>
<td>{{ t('pages.inbounds.protocol') }}</td>
<td><a-tag color="purple">{{ record.protocol }}</a-tag></td>
</tr>
<tr>
<td>{{ t('pages.inbounds.port') }}</td>
<td><a-tag>{{ record.port }}</a-tag></td>
</tr>
<tr v-if="clientCount[record.id]">
<td>{{ t('clients') }}</td>
<td><a-tag color="blue">{{ clientCount[record.id].clients }}</a-tag></td>
</tr>
<tr>
<td>{{ t('pages.inbounds.traffic') }}</td>
<td>
<a-tag>
{{ SizeFormatter.sizeFormat(record.up + record.down) }} /
<template v-if="record.total > 0">{{ SizeFormatter.sizeFormat(record.total) }}</template>
<template v-else></template>
</a-tag>
</td>
</tr>
<tr>
<td>{{ t('pages.inbounds.expireDate') }}</td>
<td>
<a-tag v-if="record.expiryTime > 0">{{ IntlUtil.formatRelativeTime(record.expiryTime) }}</a-tag>
<a-tag v-else color="purple"></a-tag>
</td>
</tr>
</tbody>
</table>
</template>
<InfoCircleOutlined class="row-info-trigger" />
</a-popover>
</template>
</template>
</a-table>
</a-space>
</a-card>
</template>
<style scoped>
.auto-refresh-title {
display: flex;
align-items: center;
gap: 8px;
}
.filter-bar {
display: flex;
align-items: center;
gap: 8px;
}
.filter-bar.mobile {
display: block;
}
.filter-bar.mobile > * {
margin-bottom: 4px;
}
.protocol-tags {
display: inline-flex;
flex-wrap: wrap;
gap: 4px;
}
.row-action-trigger,
.row-info-trigger {
font-size: 20px;
cursor: pointer;
}
.danger-item {
color: #ff4d4f;
}
/* Hide the expand chevron on rows whose inbound has no client list
* (HTTP/Mixed/Tunnel/WireGuard single-config). */
:deep(.hide-expand-icon .ant-table-row-expand-icon) {
visibility: hidden;
}
</style>