feat(frontend): collapse panel pages into a single React Router SPA

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
This commit is contained in:
MHSanaei 2026-05-24 18:37:09 +02:00
parent 538473b2cc
commit d9def73ee5
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
27 changed files with 175 additions and 384 deletions

View file

@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>API Docs</title>
</head>
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/entries/api-docs.tsx"></script>
</body>
</html>

View file

@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Clients</title>
</head>
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/entries/clients.tsx"></script>
</body>
</html>

View file

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

View file

@ -3,11 +3,11 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Overview</title> <title>3X-UI</title>
</head> </head>
<body> <body>
<div id="message"></div> <div id="message"></div>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/entries/index.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

View file

@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nodes</title>
</head>
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/entries/nodes.tsx"></script>
</body>
</html>

View file

@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Settings</title>
</head>
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/entries/settings.tsx"></script>
</body>
</html>

View file

@ -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]);
}

View file

@ -1,5 +1,6 @@
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import type { ComponentType } from 'react'; import type { ComponentType } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Drawer, Layout, Menu } from 'antd'; import { Drawer, Layout, Menu } from 'antd';
import type { MenuProps } from 'antd'; import type { MenuProps } from 'antd';
@ -23,11 +24,7 @@ import './AppSidebar.css';
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed'; const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
const DONATE_URL = 'https://donate.sanaei.dev/'; const DONATE_URL = 'https://donate.sanaei.dev/';
const LOGOUT_KEY = '__logout__';
interface AppSidebarProps {
basePath?: string;
requestUri?: string;
}
type IconName = 'dashboard' | 'user' | 'team' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs'; 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 { t } = useTranslation();
const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme(); const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
const navigate = useNavigate();
const { pathname } = useLocation();
const [collapsed, setCollapsed] = useState<boolean>(() => readCollapsed()); const [collapsed, setCollapsed] = useState<boolean>(() => readCollapsed());
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const prefix = basePath.startsWith('/') ? basePath : `/${basePath || ''}`;
const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light'; const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light';
const panelVersion = window.X_UI_CUR_VER || ''; const panelVersion = window.X_UI_CUR_VER || '';
const tabs = useMemo<{ key: string; icon: IconName; title: string }[]>(() => [ const tabs = useMemo<{ key: string; icon: IconName; title: string }[]>(() => [
{ key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') }, { key: '/', icon: 'dashboard', title: t('menu.dashboard') },
{ key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') }, { key: '/inbounds', icon: 'user', title: t('menu.inbounds') },
{ key: `${prefix}panel/clients`, icon: 'team', title: t('menu.clients') }, { key: '/clients', icon: 'team', title: t('menu.clients') },
{ key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') }, { key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
{ key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') }, { key: '/settings', icon: 'setting', title: t('menu.settings') },
{ key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') }, { key: '/xray', icon: 'tool', title: t('menu.xray') },
{ key: `${prefix}panel/api-docs`, icon: 'apidocs', title: t('menu.apiDocs') }, { key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') },
{ key: 'logout', icon: 'logout', title: t('logout') }, { key: LOGOUT_KEY, icon: 'logout', title: t('logout') },
], [prefix, t]); ], [t]);
const navItems = useMemo(() => tabs.filter((tab) => tab.icon !== 'logout'), [tabs]); const navItems = useMemo(() => tabs.filter((tab) => tab.icon !== 'logout'), [tabs]);
const utilItems = 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'] => const toMenuItems = useCallback((items: typeof tabs): MenuProps['items'] =>
items.map((tab) => { items.map((tab) => {
const Icon = iconByName[tab.icon]; const Icon = iconByName[tab.icon];
@ -137,17 +137,13 @@ export default function AppSidebar({ basePath = '', requestUri = '' }: AppSideba
[]); []);
const openLink = useCallback(async (key: string) => { const openLink = useCallback(async (key: string) => {
if (key === 'logout') { if (key === LOGOUT_KEY) {
await HttpUtil.post('/logout'); await HttpUtil.post('/logout');
window.location.href = basePath || '/'; window.location.href = window.X_UI_BASE_PATH || '/';
return; return;
} }
if (key.startsWith('http')) { navigate(key);
window.open(key); }, [navigate]);
} else {
window.location.href = key;
}
}, [basePath]);
const onMenuClick = useCallback<NonNullable<MenuProps['onClick']>>(({ key }) => { const onMenuClick = useCallback<NonNullable<MenuProps['onClick']>>(({ key }) => {
openLink(String(key)); openLink(String(key));
@ -205,7 +201,7 @@ export default function AppSidebar({ basePath = '', requestUri = '' }: AppSideba
<Menu <Menu
theme={currentTheme} theme={currentTheme}
mode="inline" mode="inline"
selectedKeys={[requestUri]} selectedKeys={[selectedKey]}
className="sider-nav" className="sider-nav"
items={toMenuItems(navItems)} items={toMenuItems(navItems)}
onClick={onMenuClick} onClick={onMenuClick}
@ -213,7 +209,7 @@ export default function AppSidebar({ basePath = '', requestUri = '' }: AppSideba
<Menu <Menu
theme={currentTheme} theme={currentTheme}
mode="inline" mode="inline"
selectedKeys={[requestUri]} selectedKeys={[selectedKey]}
className="sider-utility" className="sider-utility"
items={toMenuItems(utilItems)} items={toMenuItems(utilItems)}
onClick={onMenuClick} onClick={onMenuClick}
@ -260,7 +256,7 @@ export default function AppSidebar({ basePath = '', requestUri = '' }: AppSideba
<Menu <Menu
theme={currentTheme} theme={currentTheme}
mode="inline" mode="inline"
selectedKeys={[requestUri]} selectedKeys={[selectedKey]}
className="drawer-menu drawer-nav" className="drawer-menu drawer-nav"
items={toMenuItems(navItems)} items={toMenuItems(navItems)}
onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }} onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }}
@ -268,7 +264,7 @@ export default function AppSidebar({ basePath = '', requestUri = '' }: AppSideba
<Menu <Menu
theme={currentTheme} theme={currentTheme}
mode="inline" mode="inline"
selectedKeys={[requestUri]} selectedKeys={[selectedKey]}
className="drawer-menu drawer-utility" className="drawer-menu drawer-utility"
items={toMenuItems(utilItems)} items={toMenuItems(utilItems)}
onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }} onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }}

View file

@ -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(
<ThemeProvider>
<QueryProvider>
<ClientsPage />
</QueryProvider>
</ThemeProvider>,
);
}
});

View file

@ -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(
<ThemeProvider>
<QueryProvider>
<InboundsPage />
</QueryProvider>
</ThemeProvider>,
);
}
});

View file

@ -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(
<ThemeProvider>
<QueryProvider>
<IndexPage />
</QueryProvider>
</ThemeProvider>,
);
}
});

View file

@ -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(
<ThemeProvider>
<QueryProvider>
<NodesPage />
</QueryProvider>
</ThemeProvider>,
);
}
});

View file

@ -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(
<ThemeProvider>
<QueryProvider>
<SettingsPage />
</QueryProvider>
</ThemeProvider>,
);
}
});

View file

@ -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(
<ThemeProvider>
<QueryProvider>
<XrayPage />
</QueryProvider>
</ThemeProvider>,
);
}
});

View file

@ -0,0 +1,8 @@
import { Outlet } from 'react-router-dom';
import { useWebSocketBridge } from '@/api/websocketBridge';
export default function PanelLayout() {
useWebSocketBridge();
return <Outlet />;
}

View file

@ -1,4 +1,5 @@
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { message } from 'antd'; import { message } from 'antd';
import 'antd/dist/reset.css'; import 'antd/dist/reset.css';
@ -7,7 +8,7 @@ import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react'; import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme'; import { ThemeProvider } from '@/hooks/useTheme';
import { QueryProvider } from '@/api/QueryProvider'; import { QueryProvider } from '@/api/QueryProvider';
import ApiDocsPage from '@/pages/api-docs/ApiDocsPage'; import { router } from '@/routes';
setupAxios(); setupAxios();
applyDocumentTitle(); applyDocumentTitle();
@ -23,7 +24,7 @@ readyI18n().then(() => {
createRoot(root).render( createRoot(root).render(
<ThemeProvider> <ThemeProvider>
<QueryProvider> <QueryProvider>
<ApiDocsPage /> <RouterProvider router={router} />
</QueryProvider> </QueryProvider>
</ThemeProvider>, </ThemeProvider>,
); );

View file

@ -47,7 +47,6 @@ const curlExample = `curl -X GET \\
https://your-panel.example.com/panel/api/inbounds/list`; https://your-panel.example.com/panel/api/inbounds/list`;
const basePath = window.X_UI_BASE_PATH || ''; const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname;
const settingsHref = `${basePath}panel/settings#security`; const settingsHref = `${basePath}panel/settings#security`;
const endpointCount = (allSections as Section[]).reduce( const endpointCount = (allSections as Section[]).reduce(
@ -148,7 +147,7 @@ export default function ApiDocsPage() {
return ( return (
<ConfigProvider theme={antdThemeConfig}> <ConfigProvider theme={antdThemeConfig}>
<Layout className={pageClass}> <Layout className={pageClass}>
<AppSidebar basePath={basePath} requestUri={requestUri} /> <AppSidebar />
<Layout className="content-shell"> <Layout className="content-shell">
<Layout.Content className="content-area"> <Layout.Content className="content-area">

View file

@ -61,8 +61,6 @@ const ClientBulkAdjustModal = lazy(() => import('./ClientBulkAdjustModal'));
import '@/styles/page-cards.css'; import '@/styles/page-cards.css';
import './ClientsPage.css'; import './ClientsPage.css';
const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname;
const FILTER_STATE_KEY = 'clientsFilterState'; const FILTER_STATE_KEY = 'clientsFilterState';
type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring'; type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
@ -614,7 +612,7 @@ export default function ClientsPage() {
{messageContextHolder} {messageContextHolder}
{modalContextHolder} {modalContextHolder}
<Layout className={pageClass}> <Layout className={pageClass}>
<AppSidebar basePath={basePath} requestUri={requestUri} /> <AppSidebar />
<Layout className="content-shell"> <Layout className="content-shell">
<Layout.Content id="content-layout" className="content-area"> <Layout.Content id="content-layout" className="content-area">

View file

@ -449,15 +449,12 @@ export default function InboundsPage() {
} }
}, [hydrateInbound, openEdit, checkFallback, findClientIndex, exportInboundLinks, exportInboundSubs, exportInboundClipboard, confirmDelete, confirmResetTraffic, confirmClone, messageApi]); }, [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 ( return (
<ConfigProvider theme={antdThemeConfig}> <ConfigProvider theme={antdThemeConfig}>
{messageContextHolder} {messageContextHolder}
{modalContextHolder} {modalContextHolder}
<Layout className={`inbounds-page${isDark ? ' is-dark' : ''}${isUltra ? ' is-ultra' : ''}`}> <Layout className={`inbounds-page${isDark ? ' is-dark' : ''}${isUltra ? ' is-ultra' : ''}`}>
<AppSidebar basePath={basePath} requestUri={requestUri} /> <AppSidebar />
<Layout className="content-shell"> <Layout className="content-shell">
<Layout.Content id="content-layout" className="content-area"> <Layout.Content id="content-layout" className="content-area">

View file

@ -72,7 +72,6 @@ export default function IndexPage() {
}); });
const basePath = window.X_UI_BASE_PATH || ''; const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname;
const [showIp, setShowIp] = useState(false); const [showIp, setShowIp] = useState(false);
const [logsOpen, setLogsOpen] = useState(false); const [logsOpen, setLogsOpen] = useState(false);
@ -158,7 +157,7 @@ export default function IndexPage() {
<ConfigProvider theme={antdThemeConfig}> <ConfigProvider theme={antdThemeConfig}>
{messageContextHolder} {messageContextHolder}
<Layout className={pageClass}> <Layout className={pageClass}>
<AppSidebar basePath={basePath} requestUri={requestUri} /> <AppSidebar />
<Layout className="content-shell"> <Layout className="content-shell">
<Layout.Content className="content-area"> <Layout.Content className="content-area">

View file

@ -21,9 +21,6 @@ import { setMessageInstance } from '@/utils/messageBus';
import '@/styles/page-cards.css'; import '@/styles/page-cards.css';
import './NodesPage.css'; import './NodesPage.css';
const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname;
export default function NodesPage() { export default function NodesPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isDark, isUltra, antdThemeConfig } = useTheme(); const { isDark, isUltra, antdThemeConfig } = useTheme();
@ -112,7 +109,7 @@ export default function NodesPage() {
{messageContextHolder} {messageContextHolder}
{modalContextHolder} {modalContextHolder}
<Layout className={pageClass}> <Layout className={pageClass}>
<AppSidebar basePath={basePath} requestUri={requestUri} /> <AppSidebar />
<Layout className="content-shell"> <Layout className="content-shell">
<Layout.Content id="content-layout" className="content-area"> <Layout.Content id="content-layout" className="content-area">

View file

@ -42,8 +42,6 @@ interface ApiMsg {
success?: boolean; success?: boolean;
} }
const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname;
const tabSlugs = ['general', 'security', 'telegram', 'subscription', 'subscription-formats']; const tabSlugs = ['general', 'security', 'telegram', 'subscription', 'subscription-formats'];
function slugToKey(slug: string): string { function slugToKey(slug: string): string {
@ -270,7 +268,7 @@ export default function SettingsPage() {
{messageContextHolder} {messageContextHolder}
{modalContextHolder} {modalContextHolder}
<Layout className={pageClass}> <Layout className={pageClass}>
<AppSidebar basePath={basePath} requestUri={requestUri} /> <AppSidebar />
<Layout className="content-shell"> <Layout className="content-shell">
<Layout.Content id="content-layout" className="content-area"> <Layout.Content id="content-layout" className="content-area">

View file

@ -140,9 +140,6 @@ export default function XrayPage() {
const warpExist = !!templateSettings?.outbounds?.find((o) => o?.tag === 'warp'); const warpExist = !!templateSettings?.outbounds?.find((o) => o?.tag === 'warp');
const nordExist = !!templateSettings?.outbounds?.find((o) => o?.tag?.startsWith?.('nord-')); 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) { async function onTestOutbound(idx: number, mode: string) {
const outbound = templateSettings?.outbounds?.[idx]; const outbound = templateSettings?.outbounds?.[idx];
if (outbound) await testOutbound(idx, outbound, mode); if (outbound) await testOutbound(idx, outbound, mode);
@ -259,7 +256,7 @@ export default function XrayPage() {
{messageContextHolder} {messageContextHolder}
{modalContextHolder} {modalContextHolder}
<Layout className={pageClass}> <Layout className={pageClass}>
<AppSidebar basePath={basePath} requestUri={requestUri} /> <AppSidebar />
<Layout className="content-shell"> <Layout className="content-shell">
<Layout.Content id="content-layout" className="content-area"> <Layout.Content id="content-layout" className="content-area">

42
frontend/src/routes.tsx Normal file
View file

@ -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 <Suspense fallback={null}>{node}</Suspense>;
}
const routes: RouteObject[] = [
{
path: '/',
element: <PanelLayout />,
children: [
{ index: true, element: withSuspense(<IndexPage />) },
{ path: 'inbounds', element: withSuspense(<InboundsPage />) },
{ path: 'clients', element: withSuspense(<ClientsPage />) },
{ path: 'nodes', element: withSuspense(<NodesPage />) },
{ path: 'settings', element: withSuspense(<SettingsPage />) },
{ path: 'xray', element: withSuspense(<XrayPage />) },
{ path: 'api-docs', element: withSuspense(<ApiDocsPage />) },
],
},
];
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(),
});

View file

@ -22,22 +22,7 @@ function resolveDBPath() {
return '/etc/x-ui/x-ui.db'; return '/etc/x-ui/x-ui.db';
} }
const BASE_MIGRATED_ROUTES = { const PANEL_API_PREFIXES = ['panel/api/', 'panel/setting/', 'panel/xray/', 'panel/csrf-token'];
'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',
};
let cachedBasePath = '/'; let cachedBasePath = '/';
@ -101,7 +86,14 @@ function bypassMigratedRoute(req) {
if (url.startsWith(basePath)) { if (url.startsWith(basePath)) {
const stripped = url.slice(basePath.length); 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; return undefined;
} }
@ -172,12 +164,6 @@ export default defineConfig({
input: { input: {
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'),
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'), subpage: path.resolve(__dirname, 'subpage.html'),
}, },
output: { output: {
@ -210,6 +196,8 @@ export default defineConfig({
) return 'vendor-codemirror'; ) return 'vendor-codemirror';
if (id.includes('/node_modules/persian-calendar-suite/')) return 'vendor-jalali'; 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/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('dayjs')) return 'vendor-dayjs';
if (id.includes('axios')) return 'vendor-axios'; if (id.includes('axios')) return 'vendor-axios';
return 'vendor'; return 'vendor';

View file

@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Xray Config</title>
</head>
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/entries/xray.tsx"></script>
</body>
</html>

View file

@ -26,18 +26,23 @@ func NewXUIController(g *gin.RouterGroup) *XUIController {
} }
// initRouter sets up the main panel routes and initializes sub-controllers. // 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) { func (a *XUIController) initRouter(g *gin.RouterGroup) {
g = g.Group("/panel") g = g.Group("/panel")
g.Use(a.checkLogin) g.Use(a.checkLogin)
g.Use(middleware.CSRFMiddleware()) g.Use(middleware.CSRFMiddleware())
g.GET("/", a.index) g.GET("/", a.panelSPA)
g.GET("/inbounds", a.inbounds) g.GET("/inbounds", a.panelSPA)
g.GET("/clients", a.clients) g.GET("/clients", a.panelSPA)
g.GET("/nodes", a.nodes) g.GET("/nodes", a.panelSPA)
g.GET("/settings", a.settings) g.GET("/settings", a.panelSPA)
g.GET("/xray", a.xraySettings) g.GET("/xray", a.panelSPA)
g.GET("/api-docs", a.apiDocs) g.GET("/api-docs", a.panelSPA)
// SPA pages built by Vite don't have a server-rendered <meta name="csrf-token">, // SPA pages built by Vite don't have a server-rendered <meta name="csrf-token">,
// so they fetch the session token via this endpoint at startup and replay it // 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) a.xraySettingController = NewXraySettingController(g)
} }
// The main panel's HTML routes serve the pre-built SPA pages from distFS, // panelSPA serves the React SPA shell. Every GET under /panel/ that isn't an
// instead of rendering the legacy Go templates. Each handler is a // API endpoint returns the same index.html — React Router reads the URL and
// thin wrapper around serveDistPage so the basePath injection + // mounts the matching page on the client.
// no-cache headers stay centralised. func (a *XUIController) panelSPA(c *gin.Context) {
// index renders the main panel index page.
func (a *XUIController) index(c *gin.Context) {
serveDistPage(c, "index.html") 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. // csrfToken returns the session CSRF token to authenticated SPA clients.
// The endpoint is GET (a safe method) so it bypasses CSRFMiddleware itself, // The endpoint is GET (a safe method) so it bypasses CSRFMiddleware itself,
// but checkLogin still gates the response — anonymous callers get 401/redirect. // but checkLogin still gates the response — anonymous callers get 401/redirect.