mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-13 17:46:02 +00:00
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:
parent
f7f97bf9e5
commit
142cd40d50
7 changed files with 448 additions and 2 deletions
13
frontend/inbounds.html
Normal file
13
frontend/inbounds.html
Normal 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
16
frontend/src/inbounds.js
Normal 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');
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { ObjectUtil, NumberFormatter, SizeFormatter } from '../utils/legacy.js';
|
||||
import { ObjectUtil, NumberFormatter, SizeFormatter } from '@/utils';
|
||||
import { Inbound, Protocols } from './inbound.js';
|
||||
|
||||
export class DBInbound {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { ObjectUtil, RandomUtil, Base64 } from '../utils/legacy.js';
|
||||
import { ObjectUtil, RandomUtil, Base64 } from '@/utils';
|
||||
|
||||
export const Protocols = {
|
||||
VMESS: 'vmess',
|
||||
|
|
|
|||
199
frontend/src/pages/inbounds/InboundsPage.vue
Normal file
199
frontend/src/pages/inbounds/InboundsPage.vue
Normal 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>
|
||||
215
frontend/src/pages/inbounds/useInbounds.js
Normal file
215
frontend/src/pages/inbounds/useInbounds.js
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -17,6 +17,8 @@ const MIGRATED_ROUTES = {
|
|||
'/panel/': '/index.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
|
||||
|
|
@ -69,6 +71,7 @@ export default defineConfig({
|
|||
index: path.resolve(__dirname, 'index.html'),
|
||||
login: path.resolve(__dirname, 'login.html'),
|
||||
settings: path.resolve(__dirname, 'settings.html'),
|
||||
inbounds: path.resolve(__dirname, 'inbounds.html'),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue