From 142cd40d509837f82ff327ce4505ec59baa0484d Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Fri, 8 May 2026 13:28:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20Phase=205f-i=20=E2=80=94=20in?= =?UTF-8?q?bounds=20page=20shell=20+=20list=20fetch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/inbounds.html | 13 ++ frontend/src/inbounds.js | 16 ++ frontend/src/models/dbinbound.js | 2 +- frontend/src/models/inbound.js | 2 +- frontend/src/pages/inbounds/InboundsPage.vue | 199 +++++++++++++++++ frontend/src/pages/inbounds/useInbounds.js | 215 +++++++++++++++++++ frontend/vite.config.js | 3 + 7 files changed, 448 insertions(+), 2 deletions(-) create mode 100644 frontend/inbounds.html create mode 100644 frontend/src/inbounds.js create mode 100644 frontend/src/pages/inbounds/InboundsPage.vue create mode 100644 frontend/src/pages/inbounds/useInbounds.js diff --git a/frontend/inbounds.html b/frontend/inbounds.html new file mode 100644 index 00000000..584e5d1d --- /dev/null +++ b/frontend/inbounds.html @@ -0,0 +1,13 @@ + + + + + + 3x-ui · Inbounds + + +
+
+ + + diff --git a/frontend/src/inbounds.js b/frontend/src/inbounds.js new file mode 100644 index 00000000..3ce1324f --- /dev/null +++ b/frontend/src/inbounds.js @@ -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'); diff --git a/frontend/src/models/dbinbound.js b/frontend/src/models/dbinbound.js index daf94174..404498eb 100644 --- a/frontend/src/models/dbinbound.js +++ b/frontend/src/models/dbinbound.js @@ -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 { diff --git a/frontend/src/models/inbound.js b/frontend/src/models/inbound.js index a234ffe6..d2e2bac6 100644 --- a/frontend/src/models/inbound.js +++ b/frontend/src/models/inbound.js @@ -1,4 +1,4 @@ -import { ObjectUtil, RandomUtil, Base64 } from '../utils/legacy.js'; +import { ObjectUtil, RandomUtil, Base64 } from '@/utils'; export const Protocols = { VMESS: 'vmess', diff --git a/frontend/src/pages/inbounds/InboundsPage.vue b/frontend/src/pages/inbounds/InboundsPage.vue new file mode 100644 index 00000000..111cb224 --- /dev/null +++ b/frontend/src/pages/inbounds/InboundsPage.vue @@ -0,0 +1,199 @@ + + + + + diff --git a/frontend/src/pages/inbounds/useInbounds.js b/frontend/src/pages/inbounds/useInbounds.js new file mode 100644 index 00000000..3e6af2d2 --- /dev/null +++ b/frontend/src/pages/inbounds/useInbounds.js @@ -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, + }; +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 328d7762..a4baa643 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -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'), }, }, },