From d9def73ee50f05e1e85e7f0f28c3011fc13c553c Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 24 May 2026 18:37:09 +0200 Subject: [PATCH] feat(frontend): collapse panel pages into a single React Router SPA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the 7-entry MPA shell (index/clients/inbounds/nodes/settings/ xray/api-docs HTML files) with one main.tsx + createBrowserRouter. The Go backend now serves the same index.html for every authenticated panel route; React Router reads the URL and mounts the page from cache on subsequent navigation — no more full reloads between tabs. Frontend - main.tsx: single bootstrap (setupAxios, i18n, ThemeProvider, QueryProvider, RouterProvider) replacing 7 near-duplicate entries - routes.tsx: declarative router with lazy()-loaded pages, basename derived from window.X_UI_BASE_PATH so panels at /secret/panel work - layouts/PanelLayout.tsx: shell mount-point for the WS → queryClient bridge so connection survives navigation - api/websocketBridge.ts: subscribes the singleton WebSocketClient to queryClient and dispatches invalidate/outbounds events to cached queries (page-level useWebSocket handlers stay until Phase 3 hooks migrate) - AppSidebar: navigates via useNavigate + useLocation instead of window.location.href; drops basePath/requestUri props - Pages: drop the unused basePath/requestUri locals exposed only for the old sidebar Build - vite.config: 9 rollup inputs → 3 (index, login, subpage). Dev proxy bypass collapses /panel/* to index.html and skips API prefixes - vendor-tanstack + vendor-router chunks added to manualChunks Backend - xui.go: 7 per-page HTML handlers → one panelSPA handler serving index.html for /, /inbounds, /clients, /nodes, /settings, /xray, /api-docs. The /panel/api, /panel/setting, /panel/xray sub-routers are untouched --- frontend/api-docs.html | 13 ---- frontend/clients.html | 13 ---- frontend/inbounds.html | 13 ---- frontend/index.html | 4 +- frontend/nodes.html | 13 ---- frontend/settings.html | 13 ---- frontend/src/api/websocketBridge.ts | 62 +++++++++++++++++++ frontend/src/components/AppSidebar.tsx | 52 +++++++--------- frontend/src/entries/clients.tsx | 31 ---------- frontend/src/entries/inbounds.tsx | 31 ---------- frontend/src/entries/index.tsx | 31 ---------- frontend/src/entries/nodes.tsx | 31 ---------- frontend/src/entries/settings.tsx | 31 ---------- frontend/src/entries/xray.tsx | 31 ---------- frontend/src/layouts/PanelLayout.tsx | 8 +++ .../src/{entries/api-docs.tsx => main.tsx} | 5 +- frontend/src/pages/api-docs/ApiDocsPage.tsx | 3 +- frontend/src/pages/clients/ClientsPage.tsx | 4 +- frontend/src/pages/inbounds/InboundsPage.tsx | 5 +- frontend/src/pages/index/IndexPage.tsx | 3 +- frontend/src/pages/nodes/NodesPage.tsx | 5 +- frontend/src/pages/settings/SettingsPage.tsx | 4 +- frontend/src/pages/xray/XrayPage.tsx | 5 +- frontend/src/routes.tsx | 42 +++++++++++++ frontend/vite.config.js | 34 ++++------ frontend/xray.html | 13 ---- web/controller/xui.go | 59 +++++------------- 27 files changed, 175 insertions(+), 384 deletions(-) delete mode 100644 frontend/api-docs.html delete mode 100644 frontend/clients.html delete mode 100644 frontend/inbounds.html delete mode 100644 frontend/nodes.html delete mode 100644 frontend/settings.html create mode 100644 frontend/src/api/websocketBridge.ts delete mode 100644 frontend/src/entries/clients.tsx delete mode 100644 frontend/src/entries/inbounds.tsx delete mode 100644 frontend/src/entries/index.tsx delete mode 100644 frontend/src/entries/nodes.tsx delete mode 100644 frontend/src/entries/settings.tsx delete mode 100644 frontend/src/entries/xray.tsx create mode 100644 frontend/src/layouts/PanelLayout.tsx rename frontend/src/{entries/api-docs.tsx => main.tsx} (85%) create mode 100644 frontend/src/routes.tsx delete mode 100644 frontend/xray.html diff --git a/frontend/api-docs.html b/frontend/api-docs.html deleted file mode 100644 index e2113b94..00000000 --- a/frontend/api-docs.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - API Docs - - -
-
- - - diff --git a/frontend/clients.html b/frontend/clients.html deleted file mode 100644 index 67c76866..00000000 --- a/frontend/clients.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - Clients - - -
-
- - - diff --git a/frontend/inbounds.html b/frontend/inbounds.html deleted file mode 100644 index 52bb49c0..00000000 --- a/frontend/inbounds.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - Inbounds - - -
-
- - - diff --git a/frontend/index.html b/frontend/index.html index 196cea68..3ec1b86a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,11 +3,11 @@ - Overview + 3X-UI
- + diff --git a/frontend/nodes.html b/frontend/nodes.html deleted file mode 100644 index 908ae240..00000000 --- a/frontend/nodes.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - Nodes - - -
-
- - - diff --git a/frontend/settings.html b/frontend/settings.html deleted file mode 100644 index e753ffe1..00000000 --- a/frontend/settings.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - Settings - - -
-
- - - diff --git a/frontend/src/api/websocketBridge.ts b/frontend/src/api/websocketBridge.ts new file mode 100644 index 00000000..8a43eab1 --- /dev/null +++ b/frontend/src/api/websocketBridge.ts @@ -0,0 +1,62 @@ +import { useEffect } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; + +import { WebSocketClient } from '@/api/websocket.js'; + +type Handler = (payload: unknown) => void; + +interface SharedClient { + connect(): void; + on(event: string, fn: Handler): void; + off(event: string, fn: Handler): void; +} + +let sharedClient: SharedClient | null = null; + +function getSharedClient(): SharedClient { + if (sharedClient) return sharedClient; + const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || ''; + sharedClient = new WebSocketClient(basePath) as SharedClient; + return sharedClient; +} + +let invalidateTimer: number | null = null; + +export function useWebSocketBridge() { + const queryClient = useQueryClient(); + + useEffect(() => { + const client = getSharedClient(); + + const onInvalidate: Handler = (payload) => { + const p = payload as { type?: string } | undefined; + if (!p || (p.type !== 'inbounds' && p.type !== 'clients')) return; + if (invalidateTimer != null) clearTimeout(invalidateTimer); + invalidateTimer = window.setTimeout(() => { + invalidateTimer = null; + if (p.type === 'inbounds') { + queryClient.invalidateQueries({ queryKey: ['inbounds'] }); + } else { + queryClient.invalidateQueries({ queryKey: ['clients'] }); + } + }, 200); + }; + + const onOutbounds: Handler = (payload) => { + queryClient.setQueryData(['xray', 'outboundsTraffic'], payload); + }; + + client.on('invalidate', onInvalidate); + client.on('outbounds', onOutbounds); + client.connect(); + + return () => { + client.off('invalidate', onInvalidate); + client.off('outbounds', onOutbounds); + if (invalidateTimer != null) { + clearTimeout(invalidateTimer); + invalidateTimer = null; + } + }; + }, [queryClient]); +} diff --git a/frontend/src/components/AppSidebar.tsx b/frontend/src/components/AppSidebar.tsx index 27dc0571..0c19b773 100644 --- a/frontend/src/components/AppSidebar.tsx +++ b/frontend/src/components/AppSidebar.tsx @@ -1,5 +1,6 @@ import { useCallback, useMemo, useState } from 'react'; import type { ComponentType } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Drawer, Layout, Menu } from 'antd'; import type { MenuProps } from 'antd'; @@ -23,11 +24,7 @@ import './AppSidebar.css'; const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed'; const DONATE_URL = 'https://donate.sanaei.dev/'; - -interface AppSidebarProps { - basePath?: string; - requestUri?: string; -} +const LOGOUT_KEY = '__logout__'; type IconName = 'dashboard' | 'user' | 'team' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs'; @@ -100,31 +97,34 @@ function ThemeCycleButton({ id, isDark, isUltra, onCycle, ariaLabel }: { ); } -export default function AppSidebar({ basePath = '', requestUri = '' }: AppSidebarProps) { +export default function AppSidebar() { const { t } = useTranslation(); const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme(); + const navigate = useNavigate(); + const { pathname } = useLocation(); const [collapsed, setCollapsed] = useState(() => readCollapsed()); const [drawerOpen, setDrawerOpen] = useState(false); - const prefix = basePath.startsWith('/') ? basePath : `/${basePath || ''}`; const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light'; const panelVersion = window.X_UI_CUR_VER || ''; const tabs = useMemo<{ key: string; icon: IconName; title: string }[]>(() => [ - { key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') }, - { key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') }, - { key: `${prefix}panel/clients`, icon: 'team', title: t('menu.clients') }, - { key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') }, - { key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') }, - { key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') }, - { key: `${prefix}panel/api-docs`, icon: 'apidocs', title: t('menu.apiDocs') }, - { key: 'logout', icon: 'logout', title: t('logout') }, - ], [prefix, t]); + { key: '/', icon: 'dashboard', title: t('menu.dashboard') }, + { key: '/inbounds', icon: 'user', title: t('menu.inbounds') }, + { key: '/clients', icon: 'team', title: t('menu.clients') }, + { key: '/nodes', icon: 'cluster', title: t('menu.nodes') }, + { key: '/settings', icon: 'setting', title: t('menu.settings') }, + { key: '/xray', icon: 'tool', title: t('menu.xray') }, + { key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') }, + { key: LOGOUT_KEY, icon: 'logout', title: t('logout') }, + ], [t]); const navItems = useMemo(() => tabs.filter((tab) => tab.icon !== 'logout'), [tabs]); const utilItems = useMemo(() => tabs.filter((tab) => tab.icon === 'logout'), [tabs]); + const selectedKey = pathname === '' ? '/' : pathname; + const toMenuItems = useCallback((items: typeof tabs): MenuProps['items'] => items.map((tab) => { const Icon = iconByName[tab.icon]; @@ -137,17 +137,13 @@ export default function AppSidebar({ basePath = '', requestUri = '' }: AppSideba []); const openLink = useCallback(async (key: string) => { - if (key === 'logout') { + if (key === LOGOUT_KEY) { await HttpUtil.post('/logout'); - window.location.href = basePath || '/'; + window.location.href = window.X_UI_BASE_PATH || '/'; return; } - if (key.startsWith('http')) { - window.open(key); - } else { - window.location.href = key; - } - }, [basePath]); + navigate(key); + }, [navigate]); const onMenuClick = useCallback>(({ key }) => { openLink(String(key)); @@ -205,7 +201,7 @@ export default function AppSidebar({ basePath = '', requestUri = '' }: AppSideba { onMenuClick(info); setDrawerOpen(false); }} @@ -268,7 +264,7 @@ export default function AppSidebar({ basePath = '', requestUri = '' }: AppSideba { onMenuClick(info); setDrawerOpen(false); }} diff --git a/frontend/src/entries/clients.tsx b/frontend/src/entries/clients.tsx deleted file mode 100644 index 02fa3d2e..00000000 --- a/frontend/src/entries/clients.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { createRoot } from 'react-dom/client'; -import { message } from 'antd'; -import 'antd/dist/reset.css'; - -import { setupAxios } from '@/api/axios-init.js'; -import { applyDocumentTitle } from '@/utils'; -import { readyI18n } from '@/i18n/react'; -import { ThemeProvider } from '@/hooks/useTheme'; -import { QueryProvider } from '@/api/QueryProvider'; -import ClientsPage from '@/pages/clients/ClientsPage'; - -setupAxios(); -applyDocumentTitle(); - -const messageContainer = document.getElementById('message'); -if (messageContainer) { - message.config({ getContainer: () => messageContainer }); -} - -readyI18n().then(() => { - const root = document.getElementById('app'); - if (root) { - createRoot(root).render( - - - - - , - ); - } -}); diff --git a/frontend/src/entries/inbounds.tsx b/frontend/src/entries/inbounds.tsx deleted file mode 100644 index d09b5451..00000000 --- a/frontend/src/entries/inbounds.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { createRoot } from 'react-dom/client'; -import { message } from 'antd'; -import 'antd/dist/reset.css'; - -import { setupAxios } from '@/api/axios-init.js'; -import { applyDocumentTitle } from '@/utils'; -import { readyI18n } from '@/i18n/react'; -import { ThemeProvider } from '@/hooks/useTheme'; -import { QueryProvider } from '@/api/QueryProvider'; -import InboundsPage from '@/pages/inbounds/InboundsPage'; - -setupAxios(); -applyDocumentTitle(); - -const messageContainer = document.getElementById('message'); -if (messageContainer) { - message.config({ getContainer: () => messageContainer }); -} - -readyI18n().then(() => { - const root = document.getElementById('app'); - if (root) { - createRoot(root).render( - - - - - , - ); - } -}); diff --git a/frontend/src/entries/index.tsx b/frontend/src/entries/index.tsx deleted file mode 100644 index 1127f279..00000000 --- a/frontend/src/entries/index.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { createRoot } from 'react-dom/client'; -import { message } from 'antd'; -import 'antd/dist/reset.css'; - -import { setupAxios } from '@/api/axios-init.js'; -import { applyDocumentTitle } from '@/utils'; -import { readyI18n } from '@/i18n/react'; -import { ThemeProvider } from '@/hooks/useTheme'; -import { QueryProvider } from '@/api/QueryProvider'; -import IndexPage from '@/pages/index/IndexPage'; - -setupAxios(); -applyDocumentTitle(); - -const messageContainer = document.getElementById('message'); -if (messageContainer) { - message.config({ getContainer: () => messageContainer }); -} - -readyI18n().then(() => { - const root = document.getElementById('app'); - if (root) { - createRoot(root).render( - - - - - , - ); - } -}); diff --git a/frontend/src/entries/nodes.tsx b/frontend/src/entries/nodes.tsx deleted file mode 100644 index 82081372..00000000 --- a/frontend/src/entries/nodes.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { createRoot } from 'react-dom/client'; -import { message } from 'antd'; -import 'antd/dist/reset.css'; - -import { setupAxios } from '@/api/axios-init.js'; -import { applyDocumentTitle } from '@/utils'; -import { readyI18n } from '@/i18n/react'; -import { ThemeProvider } from '@/hooks/useTheme'; -import { QueryProvider } from '@/api/QueryProvider'; -import NodesPage from '@/pages/nodes/NodesPage'; - -setupAxios(); -applyDocumentTitle(); - -const messageContainer = document.getElementById('message'); -if (messageContainer) { - message.config({ getContainer: () => messageContainer }); -} - -readyI18n().then(() => { - const root = document.getElementById('app'); - if (root) { - createRoot(root).render( - - - - - , - ); - } -}); diff --git a/frontend/src/entries/settings.tsx b/frontend/src/entries/settings.tsx deleted file mode 100644 index 8cb6b9b5..00000000 --- a/frontend/src/entries/settings.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { createRoot } from 'react-dom/client'; -import { message } from 'antd'; -import 'antd/dist/reset.css'; - -import { setupAxios } from '@/api/axios-init.js'; -import { applyDocumentTitle } from '@/utils'; -import { readyI18n } from '@/i18n/react'; -import { ThemeProvider } from '@/hooks/useTheme'; -import { QueryProvider } from '@/api/QueryProvider'; -import SettingsPage from '@/pages/settings/SettingsPage'; - -setupAxios(); -applyDocumentTitle(); - -const messageContainer = document.getElementById('message'); -if (messageContainer) { - message.config({ getContainer: () => messageContainer }); -} - -readyI18n().then(() => { - const root = document.getElementById('app'); - if (root) { - createRoot(root).render( - - - - - , - ); - } -}); diff --git a/frontend/src/entries/xray.tsx b/frontend/src/entries/xray.tsx deleted file mode 100644 index 0db35939..00000000 --- a/frontend/src/entries/xray.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { createRoot } from 'react-dom/client'; -import { message } from 'antd'; -import 'antd/dist/reset.css'; - -import { setupAxios } from '@/api/axios-init.js'; -import { applyDocumentTitle } from '@/utils'; -import { readyI18n } from '@/i18n/react'; -import { ThemeProvider } from '@/hooks/useTheme'; -import { QueryProvider } from '@/api/QueryProvider'; -import XrayPage from '@/pages/xray/XrayPage'; - -setupAxios(); -applyDocumentTitle(); - -const messageContainer = document.getElementById('message'); -if (messageContainer) { - message.config({ getContainer: () => messageContainer }); -} - -readyI18n().then(() => { - const root = document.getElementById('app'); - if (root) { - createRoot(root).render( - - - - - , - ); - } -}); diff --git a/frontend/src/layouts/PanelLayout.tsx b/frontend/src/layouts/PanelLayout.tsx new file mode 100644 index 00000000..fce2ce54 --- /dev/null +++ b/frontend/src/layouts/PanelLayout.tsx @@ -0,0 +1,8 @@ +import { Outlet } from 'react-router-dom'; + +import { useWebSocketBridge } from '@/api/websocketBridge'; + +export default function PanelLayout() { + useWebSocketBridge(); + return ; +} diff --git a/frontend/src/entries/api-docs.tsx b/frontend/src/main.tsx similarity index 85% rename from frontend/src/entries/api-docs.tsx rename to frontend/src/main.tsx index adbf2aae..837c8400 100644 --- a/frontend/src/entries/api-docs.tsx +++ b/frontend/src/main.tsx @@ -1,4 +1,5 @@ import { createRoot } from 'react-dom/client'; +import { RouterProvider } from 'react-router-dom'; import { message } from 'antd'; import 'antd/dist/reset.css'; @@ -7,7 +8,7 @@ import { applyDocumentTitle } from '@/utils'; import { readyI18n } from '@/i18n/react'; import { ThemeProvider } from '@/hooks/useTheme'; import { QueryProvider } from '@/api/QueryProvider'; -import ApiDocsPage from '@/pages/api-docs/ApiDocsPage'; +import { router } from '@/routes'; setupAxios(); applyDocumentTitle(); @@ -23,7 +24,7 @@ readyI18n().then(() => { createRoot(root).render( - + , ); diff --git a/frontend/src/pages/api-docs/ApiDocsPage.tsx b/frontend/src/pages/api-docs/ApiDocsPage.tsx index 4d82c1a3..6a1ffa1d 100644 --- a/frontend/src/pages/api-docs/ApiDocsPage.tsx +++ b/frontend/src/pages/api-docs/ApiDocsPage.tsx @@ -47,7 +47,6 @@ const curlExample = `curl -X GET \\ https://your-panel.example.com/panel/api/inbounds/list`; const basePath = window.X_UI_BASE_PATH || ''; -const requestUri = window.location.pathname; const settingsHref = `${basePath}panel/settings#security`; const endpointCount = (allSections as Section[]).reduce( @@ -148,7 +147,7 @@ export default function ApiDocsPage() { return ( - + diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx index f94ce883..b04c22a9 100644 --- a/frontend/src/pages/clients/ClientsPage.tsx +++ b/frontend/src/pages/clients/ClientsPage.tsx @@ -61,8 +61,6 @@ const ClientBulkAdjustModal = lazy(() => import('./ClientBulkAdjustModal')); import '@/styles/page-cards.css'; import './ClientsPage.css'; -const basePath = window.X_UI_BASE_PATH || ''; -const requestUri = window.location.pathname; const FILTER_STATE_KEY = 'clientsFilterState'; type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring'; @@ -614,7 +612,7 @@ export default function ClientsPage() { {messageContextHolder} {modalContextHolder} - + diff --git a/frontend/src/pages/inbounds/InboundsPage.tsx b/frontend/src/pages/inbounds/InboundsPage.tsx index 1192cb2b..bcf5d960 100644 --- a/frontend/src/pages/inbounds/InboundsPage.tsx +++ b/frontend/src/pages/inbounds/InboundsPage.tsx @@ -449,15 +449,12 @@ export default function InboundsPage() { } }, [hydrateInbound, openEdit, checkFallback, findClientIndex, exportInboundLinks, exportInboundSubs, exportInboundClipboard, confirmDelete, confirmResetTraffic, confirmClone, messageApi]); - const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || ''; - const requestUri = typeof window !== 'undefined' ? window.location.pathname : ''; - return ( {messageContextHolder} {modalContextHolder} - + diff --git a/frontend/src/pages/index/IndexPage.tsx b/frontend/src/pages/index/IndexPage.tsx index 66d431ad..21bfcb3a 100644 --- a/frontend/src/pages/index/IndexPage.tsx +++ b/frontend/src/pages/index/IndexPage.tsx @@ -72,7 +72,6 @@ export default function IndexPage() { }); const basePath = window.X_UI_BASE_PATH || ''; - const requestUri = window.location.pathname; const [showIp, setShowIp] = useState(false); const [logsOpen, setLogsOpen] = useState(false); @@ -158,7 +157,7 @@ export default function IndexPage() { {messageContextHolder} - + diff --git a/frontend/src/pages/nodes/NodesPage.tsx b/frontend/src/pages/nodes/NodesPage.tsx index 83932e2b..080273e9 100644 --- a/frontend/src/pages/nodes/NodesPage.tsx +++ b/frontend/src/pages/nodes/NodesPage.tsx @@ -21,9 +21,6 @@ import { setMessageInstance } from '@/utils/messageBus'; import '@/styles/page-cards.css'; import './NodesPage.css'; -const basePath = window.X_UI_BASE_PATH || ''; -const requestUri = window.location.pathname; - export default function NodesPage() { const { t } = useTranslation(); const { isDark, isUltra, antdThemeConfig } = useTheme(); @@ -112,7 +109,7 @@ export default function NodesPage() { {messageContextHolder} {modalContextHolder} - + diff --git a/frontend/src/pages/settings/SettingsPage.tsx b/frontend/src/pages/settings/SettingsPage.tsx index c4342b11..695b1c73 100644 --- a/frontend/src/pages/settings/SettingsPage.tsx +++ b/frontend/src/pages/settings/SettingsPage.tsx @@ -42,8 +42,6 @@ interface ApiMsg { success?: boolean; } -const basePath = window.X_UI_BASE_PATH || ''; -const requestUri = window.location.pathname; const tabSlugs = ['general', 'security', 'telegram', 'subscription', 'subscription-formats']; function slugToKey(slug: string): string { @@ -270,7 +268,7 @@ export default function SettingsPage() { {messageContextHolder} {modalContextHolder} - + diff --git a/frontend/src/pages/xray/XrayPage.tsx b/frontend/src/pages/xray/XrayPage.tsx index 20646b4f..28577aaf 100644 --- a/frontend/src/pages/xray/XrayPage.tsx +++ b/frontend/src/pages/xray/XrayPage.tsx @@ -140,9 +140,6 @@ export default function XrayPage() { const warpExist = !!templateSettings?.outbounds?.find((o) => o?.tag === 'warp'); const nordExist = !!templateSettings?.outbounds?.find((o) => o?.tag?.startsWith?.('nord-')); - const basePath = window.X_UI_BASE_PATH || ''; - const requestUri = window.location.pathname; - async function onTestOutbound(idx: number, mode: string) { const outbound = templateSettings?.outbounds?.[idx]; if (outbound) await testOutbound(idx, outbound, mode); @@ -259,7 +256,7 @@ export default function XrayPage() { {messageContextHolder} {modalContextHolder} - + diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx new file mode 100644 index 00000000..d72180ac --- /dev/null +++ b/frontend/src/routes.tsx @@ -0,0 +1,42 @@ +import { lazy, Suspense } from 'react'; +import { createBrowserRouter, type RouteObject } from 'react-router-dom'; + +import PanelLayout from '@/layouts/PanelLayout'; + +const IndexPage = lazy(() => import('@/pages/index/IndexPage')); +const InboundsPage = lazy(() => import('@/pages/inbounds/InboundsPage')); +const ClientsPage = lazy(() => import('@/pages/clients/ClientsPage')); +const NodesPage = lazy(() => import('@/pages/nodes/NodesPage')); +const SettingsPage = lazy(() => import('@/pages/settings/SettingsPage')); +const XrayPage = lazy(() => import('@/pages/xray/XrayPage')); +const ApiDocsPage = lazy(() => import('@/pages/api-docs/ApiDocsPage')); + +function withSuspense(node: React.ReactNode) { + return {node}; +} + +const routes: RouteObject[] = [ + { + path: '/', + element: , + children: [ + { index: true, element: withSuspense() }, + { path: 'inbounds', element: withSuspense() }, + { path: 'clients', element: withSuspense() }, + { path: 'nodes', element: withSuspense() }, + { path: 'settings', element: withSuspense() }, + { path: 'xray', element: withSuspense() }, + { path: 'api-docs', element: withSuspense() }, + ], + }, +]; + +function computeBasename() { + const raw = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '/'; + const trimmed = raw.replace(/\/+$/, ''); + return `${trimmed}/panel`; +} + +export const router = createBrowserRouter(routes, { + basename: computeBasename(), +}); diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 9a860191..a54146c1 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -22,22 +22,7 @@ function resolveDBPath() { return '/etc/x-ui/x-ui.db'; } -const BASE_MIGRATED_ROUTES = { - 'panel': '/index.html', - 'panel/': '/index.html', - 'panel/settings': '/settings.html', - 'panel/settings/': '/settings.html', - 'panel/inbounds': '/inbounds.html', - 'panel/inbounds/': '/inbounds.html', - 'panel/clients': '/clients.html', - 'panel/clients/': '/clients.html', - 'panel/xray': '/xray.html', - 'panel/xray/': '/xray.html', - 'panel/nodes': '/nodes.html', - 'panel/nodes/': '/nodes.html', - 'panel/api-docs': '/api-docs.html', - 'panel/api-docs/': '/api-docs.html', -}; +const PANEL_API_PREFIXES = ['panel/api/', 'panel/setting/', 'panel/xray/', 'panel/csrf-token']; let cachedBasePath = '/'; @@ -101,7 +86,14 @@ function bypassMigratedRoute(req) { if (url.startsWith(basePath)) { const stripped = url.slice(basePath.length); - if (stripped in BASE_MIGRATED_ROUTES) return BASE_MIGRATED_ROUTES[stripped]; + for (const prefix of PANEL_API_PREFIXES) { + if (stripped === prefix.replace(/\/$/, '') || stripped.startsWith(prefix)) { + return undefined; + } + } + if (stripped === 'panel' || stripped === 'panel/' || stripped.startsWith('panel/')) { + return '/index.html'; + } } return undefined; } @@ -172,12 +164,6 @@ export default defineConfig({ input: { index: path.resolve(__dirname, 'index.html'), login: path.resolve(__dirname, 'login.html'), - settings: path.resolve(__dirname, 'settings.html'), - inbounds: path.resolve(__dirname, 'inbounds.html'), - clients: path.resolve(__dirname, 'clients.html'), - xray: path.resolve(__dirname, 'xray.html'), - nodes: path.resolve(__dirname, 'nodes.html'), - apiDocs: path.resolve(__dirname, 'api-docs.html'), subpage: path.resolve(__dirname, 'subpage.html'), }, output: { @@ -210,6 +196,8 @@ export default defineConfig({ ) return 'vendor-codemirror'; if (id.includes('/node_modules/persian-calendar-suite/')) return 'vendor-jalali'; if (id.includes('/node_modules/otpauth/')) return 'vendor-otpauth'; + if (id.includes('/node_modules/@tanstack/')) return 'vendor-tanstack'; + if (id.includes('/node_modules/react-router')) return 'vendor-router'; if (id.includes('dayjs')) return 'vendor-dayjs'; if (id.includes('axios')) return 'vendor-axios'; return 'vendor'; diff --git a/frontend/xray.html b/frontend/xray.html deleted file mode 100644 index eb743fe5..00000000 --- a/frontend/xray.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - Xray Config - - -
-
- - - diff --git a/web/controller/xui.go b/web/controller/xui.go index 2da9a52e..fcb80a5c 100644 --- a/web/controller/xui.go +++ b/web/controller/xui.go @@ -26,18 +26,23 @@ func NewXUIController(g *gin.RouterGroup) *XUIController { } // initRouter sets up the main panel routes and initializes sub-controllers. +// +// The HTML routes all hand the same single-page-app shell (index.html) to the +// browser; React Router takes over and renders the correct page from the URL. +// The /panel/api, /panel/setting, /panel/xray sub-routers register POST/JSON +// endpoints on different paths and stay untouched by the shell handler. func (a *XUIController) initRouter(g *gin.RouterGroup) { g = g.Group("/panel") g.Use(a.checkLogin) g.Use(middleware.CSRFMiddleware()) - g.GET("/", a.index) - g.GET("/inbounds", a.inbounds) - g.GET("/clients", a.clients) - g.GET("/nodes", a.nodes) - g.GET("/settings", a.settings) - g.GET("/xray", a.xraySettings) - g.GET("/api-docs", a.apiDocs) + g.GET("/", a.panelSPA) + g.GET("/inbounds", a.panelSPA) + g.GET("/clients", a.panelSPA) + g.GET("/nodes", a.panelSPA) + g.GET("/settings", a.panelSPA) + g.GET("/xray", a.panelSPA) + g.GET("/api-docs", a.panelSPA) // SPA pages built by Vite don't have a server-rendered , // so they fetch the session token via this endpoint at startup and replay it @@ -48,45 +53,13 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) { a.xraySettingController = NewXraySettingController(g) } -// The main panel's HTML routes serve the pre-built SPA pages from distFS, -// instead of rendering the legacy Go templates. Each handler is a -// thin wrapper around serveDistPage so the basePath injection + -// no-cache headers stay centralised. - -// index renders the main panel index page. -func (a *XUIController) index(c *gin.Context) { +// panelSPA serves the React SPA shell. Every GET under /panel/ that isn't an +// API endpoint returns the same index.html — React Router reads the URL and +// mounts the matching page on the client. +func (a *XUIController) panelSPA(c *gin.Context) { serveDistPage(c, "index.html") } -// inbounds renders the inbounds management page. -func (a *XUIController) inbounds(c *gin.Context) { - serveDistPage(c, "inbounds.html") -} - -func (a *XUIController) clients(c *gin.Context) { - serveDistPage(c, "clients.html") -} - -// nodes renders the multi-panel nodes management page. -func (a *XUIController) nodes(c *gin.Context) { - serveDistPage(c, "nodes.html") -} - -// settings renders the settings management page. -func (a *XUIController) settings(c *gin.Context) { - serveDistPage(c, "settings.html") -} - -// xraySettings renders the Xray settings page. -func (a *XUIController) xraySettings(c *gin.Context) { - serveDistPage(c, "xray.html") -} - -// apiDocs renders the in-panel API documentation page. -func (a *XUIController) apiDocs(c *gin.Context) { - serveDistPage(c, "api-docs.html") -} - // csrfToken returns the session CSRF token to authenticated SPA clients. // The endpoint is GET (a safe method) so it bypasses CSRFMiddleware itself, // but checkLogin still gates the response — anonymous callers get 401/redirect.