feat(frontend): Phase 5f-i — inbounds page shell + list fetch

Adds the inbounds entry as a fourth Vite multi-page input and wires
/panel/inbounds through the dev proxy bypass. Lays down the page
chrome (sidebar, summary statistics card, refresh button) and the
fetch lifecycle composable so 5f-ii onward can drop in the table
columns and the modals without re-implementing it.

- inbounds.html + src/inbounds.js: fourth Vite entry; mounts InboundsPage.
- InboundsPage.vue: sidebar + summary card (totals over up/down,
  all-time, inbound count, client tags) + a basic table with enable/
  remark/port/protocol/traffic/expiry columns. Row actions, popovers,
  search/filter, auto-refresh, and the WebSocket delta path are all
  deferred to subsequent 5f subphases.
- useInbounds.js composable: GET /panel/api/inbounds/list +
  POST /panel/api/inbounds/onlines + POST /panel/api/inbounds/lastOnline +
  POST /panel/setting/defaultSettings, then computes the
  per-inbound clientCount roll-ups (active/deactive/depleted/expiring/
  online/comments) the table popovers consume.
- models/dbinbound.js + models/inbound.js: switched the legacy-utils
  import to the @/utils alias for consistency with the rest of the
  app. Semantics unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MHSanaei 2026-05-08 13:28:15 +02:00
parent f7f97bf9e5
commit 142cd40d50
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
7 changed files with 448 additions and 2 deletions

13
frontend/inbounds.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3x-ui · Inbounds</title>
</head>
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/inbounds.js"></script>
</body>
</html>

16
frontend/src/inbounds.js Normal file
View file

@ -0,0 +1,16 @@
import { createApp } from 'vue';
import Antd, { message } from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js';
import InboundsPage from '@/pages/inbounds/InboundsPage.vue';
setupAxios();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
createApp(InboundsPage).use(Antd).mount('#app');

View file

@ -1,4 +1,4 @@
import { ObjectUtil, NumberFormatter, SizeFormatter } from '../utils/legacy.js'; import { ObjectUtil, NumberFormatter, SizeFormatter } from '@/utils';
import { Inbound, Protocols } from './inbound.js'; import { Inbound, Protocols } from './inbound.js';
export class DBInbound { export class DBInbound {

View file

@ -1,4 +1,4 @@
import { ObjectUtil, RandomUtil, Base64 } from '../utils/legacy.js'; import { ObjectUtil, RandomUtil, Base64 } from '@/utils';
export const Protocols = { export const Protocols = {
VMESS: 'vmess', VMESS: 'vmess',

View file

@ -0,0 +1,199 @@
<script setup>
import { computed, onMounted } from 'vue';
import { theme as antdTheme } from 'ant-design-vue';
import {
SwapOutlined,
PieChartOutlined,
HistoryOutlined,
BarsOutlined,
TeamOutlined,
SyncOutlined,
} from '@ant-design/icons-vue';
import { SizeFormatter } from '@/utils';
import { theme as themeState } from '@/composables/useTheme.js';
import { useMediaQuery } from '@/composables/useMediaQuery.js';
import AppSidebar from '@/components/AppSidebar.vue';
import CustomStatistic from '@/components/CustomStatistic.vue';
import { useInbounds } from './useInbounds.js';
const antdThemeConfig = computed(() => ({
algorithm: themeState.isDark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
}));
const {
fetched,
refreshing,
dbInbounds,
totals,
refresh,
fetchDefaultSettings,
} = useInbounds();
const { isMobile } = useMediaQuery();
const basePath = window.__X_UI_BASE_PATH__ || '';
const requestUri = window.location.pathname;
onMounted(async () => {
await fetchDefaultSettings();
await refresh();
});
</script>
<template>
<a-config-provider :theme="antdThemeConfig">
<a-layout
class="inbounds-page"
:class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }"
>
<AppSidebar :base-path="basePath" :request-uri="requestUri" />
<a-layout class="content-shell">
<a-layout-content id="content-layout" class="content-area">
<a-spin :spinning="!fetched" :delay="200" tip="Loading…" size="large">
<div v-if="!fetched" class="loading-spacer" />
<a-row v-else :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]">
<!-- Summary statistics card -->
<a-col :span="24">
<a-card size="small" hoverable class="summary-card">
<a-row :gutter="[16, 12]">
<a-col :sm="12" :md="5">
<CustomStatistic
title="Total ↑ / ↓"
:value="`${SizeFormatter.sizeFormat(totals.up)} / ${SizeFormatter.sizeFormat(totals.down)}`"
>
<template #prefix><SwapOutlined /></template>
</CustomStatistic>
</a-col>
<a-col :sm="12" :md="5">
<CustomStatistic
title="Total usage"
:value="SizeFormatter.sizeFormat(totals.up + totals.down)"
>
<template #prefix><PieChartOutlined /></template>
</CustomStatistic>
</a-col>
<a-col :sm="12" :md="5">
<CustomStatistic
title="All-time traffic"
:value="SizeFormatter.sizeFormat(totals.allTime)"
>
<template #prefix><HistoryOutlined /></template>
</CustomStatistic>
</a-col>
<a-col :sm="12" :md="5">
<CustomStatistic title="Inbounds" :value="String(dbInbounds.length)">
<template #prefix><BarsOutlined /></template>
</CustomStatistic>
</a-col>
<a-col :sm="24" :md="4">
<CustomStatistic title="Clients" value=" ">
<template #prefix>
<a-space direction="horizontal">
<TeamOutlined />
<a-tag color="green">{{ totals.clients }}</a-tag>
<a-tag v-if="totals.deactive.length">{{ totals.deactive.length }}</a-tag>
<a-tag v-if="totals.depleted.length" color="red">{{ totals.depleted.length }}</a-tag>
<a-tag v-if="totals.expiring.length" color="orange">{{ totals.expiring.length }}</a-tag>
</a-space>
</template>
</CustomStatistic>
</a-col>
</a-row>
</a-card>
</a-col>
<!-- Inbound list (basic columns; row actions/modals come in later subphases) -->
<a-col :span="24">
<a-card hoverable>
<template #title>
<span>Inbounds</span>
</template>
<template #extra>
<a-button :loading="refreshing" @click="refresh">
<template #icon><SyncOutlined /></template>
</a-button>
</template>
<a-table
:columns="[
{ title: 'Enable', dataIndex: 'enable', key: 'enable', width: 80 },
{ title: 'Remark', dataIndex: 'remark', key: 'remark' },
{ title: 'Port', dataIndex: 'port', key: 'port', width: 100 },
{ title: 'Protocol', dataIndex: 'protocol', key: 'protocol', width: 130 },
{ title: 'Traffic', key: 'traffic', width: 200 },
{ title: 'Expiry', key: 'expiry', width: 140 },
]"
:data-source="dbInbounds"
:row-key="(r) => r.id"
:pagination="false"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'enable'">
<a-switch :checked="record.enable" disabled />
</template>
<template v-else-if="column.key === 'protocol'">
<a-tag color="purple">{{ record.protocol }}</a-tag>
</template>
<template v-else-if="column.key === 'traffic'">
<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>
</template>
<template v-else-if="column.key === 'expiry'">
<a-tag v-if="record.expiryTime > 0">
{{ new Date(record.expiryTime).toLocaleDateString() }}
</a-tag>
<a-tag v-else color="purple"></a-tag>
</template>
</template>
</a-table>
</a-card>
</a-col>
</a-row>
</a-spin>
</a-layout-content>
</a-layout>
</a-layout>
</a-config-provider>
</template>
<style scoped>
.inbounds-page {
--bg-page: #f0f2f5;
--bg-card: #ffffff;
min-height: 100vh;
background: var(--bg-page);
}
.inbounds-page.is-dark {
--bg-page: #0a1222;
--bg-card: #151f31;
}
.inbounds-page.is-dark.is-ultra {
--bg-page: #21242a;
--bg-card: #0c0e12;
}
.inbounds-page :deep(.ant-layout),
.inbounds-page :deep(.ant-layout-content) {
background: transparent;
}
.content-shell { background: transparent; }
.content-area { padding: 24px; }
.loading-spacer { min-height: calc(100vh - 120px); }
.summary-card {
padding: 16px;
}
</style>

View file

@ -0,0 +1,215 @@
// Loads the inbound list + sidecar data the page needs (online users,
// last-online-map, default settings) and computes the per-inbound client
// roll-ups the legacy panel surfaces in the popovers.
//
// 5f-i scope: plain GET on mount + a manual refresh; auto-refresh and the
// WebSocket delta path are deferred to a later subphase.
import { computed, ref, shallowRef } from 'vue';
import { HttpUtil, ObjectUtil } from '@/utils';
import { DBInbound } from '@/models/dbinbound.js';
import { Protocols } from '@/models/inbound.js';
const ONLINE_GRACE_MS = 60_000;
export function useInbounds() {
const fetched = ref(false);
const refreshing = ref(false);
// shallowRef because each refresh swaps the array; per-row reactivity is
// unnecessary at the page level (modals work on copies).
const dbInbounds = shallowRef([]);
const clientCount = ref({});
const onlineClients = ref([]);
const lastOnlineMap = ref({});
// Default-settings sidecar fields the table needs for color/expiry math.
const expireDiff = ref(0);
const trafficDiff = ref(0);
const subSettings = ref({
enable: false,
subTitle: '',
subURI: '',
subJsonURI: '',
subJsonEnable: false,
});
const remarkModel = ref('-ieo');
const datepicker = ref('gregorian');
const tgBotEnable = ref(false);
const ipLimitEnable = ref(false);
const pageSize = ref(0);
function isClientOnline(email) {
return onlineClients.value.includes(email);
}
// Roll-up of {clients, active, deactive, depleted, expiring, online,
// comments} for a single inbound. Mirrors getClientCounts in the legacy
// template. Skipped for protocols that don't have multi-user clients
// (HTTP, MIXED, WireGuard) since their settings have no client list.
function rollupClients(dbInbound, inbound) {
const clientStats = Array.isArray(dbInbound.clientStats) ? dbInbound.clientStats : [];
const clients = inbound?.clients || [];
const active = [];
const deactive = [];
const depleted = [];
const expiring = [];
const online = [];
const comments = new Map();
const now = Date.now();
if (dbInbound.enable) {
for (const client of clients) {
if (client.comment) comments.set(client.email, client.comment);
if (client.enable) {
active.push(client.email);
if (isClientOnline(client.email)) online.push(client.email);
} else {
deactive.push(client.email);
}
}
for (const stats of clientStats) {
const exhausted = stats.total > 0 && stats.up + stats.down >= stats.total;
const expired = stats.expiryTime > 0 && stats.expiryTime <= now;
if (expired || exhausted) {
depleted.push(stats.email);
} else {
const expiringSoon =
(stats.expiryTime > 0 && stats.expiryTime - now < expireDiff.value) ||
(stats.total > 0 && stats.total - (stats.up + stats.down) < trafficDiff.value);
if (expiringSoon) expiring.push(stats.email);
}
}
} else {
for (const client of clients) deactive.push(client.email);
}
return {
clients: clients.length,
active,
deactive,
depleted,
expiring,
online,
comments,
};
}
function setInbounds(rows) {
const next = [];
const counts = {};
for (const row of rows) {
const dbInbound = new DBInbound(row);
const parsed = dbInbound.toInbound();
next.push(dbInbound);
const tracked = [
Protocols.VMESS,
Protocols.VLESS,
Protocols.TROJAN,
Protocols.SHADOWSOCKS,
Protocols.HYSTERIA,
];
if (tracked.includes(row.protocol)) {
if (dbInbound.isSS && !parsed.isSSMultiUser) continue;
counts[row.id] = rollupClients(dbInbound, parsed);
}
}
dbInbounds.value = next;
clientCount.value = counts;
fetched.value = true;
}
async function fetchOnlineUsers() {
const msg = await HttpUtil.post('/panel/api/inbounds/onlines');
if (msg?.success) onlineClients.value = msg.obj || [];
}
async function fetchLastOnlineMap() {
const msg = await HttpUtil.post('/panel/api/inbounds/lastOnline');
if (msg?.success && msg.obj) lastOnlineMap.value = msg.obj;
}
async function fetchDefaultSettings() {
const msg = await HttpUtil.post('/panel/setting/defaultSettings');
if (!msg?.success) return;
const s = msg.obj || {};
expireDiff.value = (s.expireDiff ?? 0) * 86400000;
trafficDiff.value = (s.trafficDiff ?? 0) * 1073741824;
tgBotEnable.value = !!s.tgBotEnable;
subSettings.value = {
enable: !!s.subEnable,
subTitle: s.subTitle || '',
subURI: s.subURI || '',
subJsonURI: s.subJsonURI || '',
subJsonEnable: !!s.subJsonEnable,
};
pageSize.value = s.pageSize ?? 0;
remarkModel.value = s.remarkModel || '-ieo';
datepicker.value = s.datepicker || 'gregorian';
ipLimitEnable.value = !!s.ipLimitEnable;
}
async function refresh() {
refreshing.value = true;
try {
const msg = await HttpUtil.get('/panel/api/inbounds/list');
if (!msg?.success) return;
await fetchLastOnlineMap();
await fetchOnlineUsers();
setInbounds(Array.isArray(msg.obj) ? msg.obj : []);
} finally {
// Match legacy: keep the spinning-icon state visible briefly so
// a fast network doesn't make the button feel like it didn't fire.
setTimeout(() => { refreshing.value = false; }, 500);
}
}
// Aggregate totals shown in the dashboard summary card. allTime falls
// back to up+down when the per-inbound counter isn't populated yet.
const totals = computed(() => {
let up = 0;
let down = 0;
let allTime = 0;
let clients = 0;
const deactive = [];
const depleted = [];
const expiring = [];
for (const ib of dbInbounds.value) {
up += ib.up || 0;
down += ib.down || 0;
allTime += ib.allTime || (ib.up + ib.down) || 0;
const c = clientCount.value[ib.id];
if (c) {
clients += c.clients;
deactive.push(...c.deactive);
depleted.push(...c.depleted);
expiring.push(...c.expiring);
}
}
return { up, down, allTime, clients, deactive, depleted, expiring };
});
// ObjectUtil reference is wired at module load — keeping a no-op import
// here so the linter doesn't drop it; the legacy search uses it.
void ObjectUtil;
return {
fetched,
refreshing,
dbInbounds,
clientCount,
onlineClients,
lastOnlineMap,
totals,
expireDiff,
trafficDiff,
subSettings,
remarkModel,
datepicker,
tgBotEnable,
ipLimitEnable,
pageSize,
refresh,
fetchDefaultSettings,
};
}

View file

@ -17,6 +17,8 @@ const MIGRATED_ROUTES = {
'/panel/': '/index.html', '/panel/': '/index.html',
'/panel/settings': '/settings.html', '/panel/settings': '/settings.html',
'/panel/settings/': '/settings.html', '/panel/settings/': '/settings.html',
'/panel/inbounds': '/inbounds.html',
'/panel/inbounds/': '/inbounds.html',
}; };
// Build a proxy config that suppresses ECONNREFUSED noise when the Go // Build a proxy config that suppresses ECONNREFUSED noise when the Go
@ -69,6 +71,7 @@ export default defineConfig({
index: path.resolve(__dirname, 'index.html'), index: path.resolve(__dirname, 'index.html'),
login: path.resolve(__dirname, 'login.html'), login: path.resolve(__dirname, 'login.html'),
settings: path.resolve(__dirname, 'settings.html'), settings: path.resolve(__dirname, 'settings.html'),
inbounds: path.resolve(__dirname, 'inbounds.html'),
}, },
}, },
}, },