3x-ui/frontend/src/pages/inbounds/InboundList.vue
MHSanaei 69ca4f803e
fix(frontend): redesign expand-row + retheme client visuals
When you expanded an inbound row, the nested <a-table> inside
ClientRowTable burst out of the parent's scroll-x box — its
.ant-spin-container ended up wider than the parent's narrow
.ant-table-cell, so the child looked oversized while the parent looked
squeezed. Replace the nested table with a CSS-grid layout that owns
its sizing, sits flush inside the expanded cell, and collapses to a
3-column layout on mobile (action menu, client identity, info popover).

While in there, fix three other client-row visuals:
- The Unicode infinity glyph (U+221E) renders as an "m"-shaped
  character in some system fonts (Windows Segoe UI in particular).
  Add a shared <InfinityIcon /> SVG component (legacy panel's path)
  and use it in ClientRowTable, InboundList, and InboundInfoModal —
  desktop and mobile cells.
- The "unlimited quota" traffic bar passed :percent="100" with no
  stroke-color, so AD-Vue auto-coloured it success-green. Pin it to
  the AD-Vue purple token (#722ed1) so it reads as the no-limit
  sentinel rather than another usage state.
- ColorUtils + the in-row statsExpColor still hardcoded the legacy
  teal/orange/red/purple palette (#008771 / #f37b24 / #cf3c3c /
  #7a316f). Map them onto AD-Vue 4's success/warning/danger/purple
  tokens (#52c41a / #faad14 / #ff4d4f / #722ed1) so badges, tags,
  and progress bars all match the rest of the panel.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 18:32:37 +02:00

568 lines
23 KiB
Vue

<script setup>
import { computed, onBeforeUnmount, 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 InfinityIcon from '@/components/InfinityIcon.vue';
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();
});
// Without this, a stale setInterval keeps firing emit('refresh') after
// the component unmounts, which Vue surfaces as "emitsOptions" /
// "__asyncLoader" exceptions on the next tick.
onBeforeUnmount(stopAutoRefresh);
// 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>
<InfinityIcon v-else />
</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"><InfinityIcon /></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>
<InfinityIcon v-else />
</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"><InfinityIcon /></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>