mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 12:44:22 +00:00
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:
parent
538473b2cc
commit
d9def73ee5
27 changed files with 175 additions and 384 deletions
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -3,11 +3,11 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Overview</title>
|
||||
<title>3X-UI</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/index.tsx"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
62
frontend/src/api/websocketBridge.ts
Normal file
62
frontend/src/api/websocketBridge.ts
Normal 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]);
|
||||
}
|
||||
|
|
@ -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<boolean>(() => 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<NonNullable<MenuProps['onClick']>>(({ key }) => {
|
||||
openLink(String(key));
|
||||
|
|
@ -205,7 +201,7 @@ export default function AppSidebar({ basePath = '', requestUri = '' }: AppSideba
|
|||
<Menu
|
||||
theme={currentTheme}
|
||||
mode="inline"
|
||||
selectedKeys={[requestUri]}
|
||||
selectedKeys={[selectedKey]}
|
||||
className="sider-nav"
|
||||
items={toMenuItems(navItems)}
|
||||
onClick={onMenuClick}
|
||||
|
|
@ -213,7 +209,7 @@ export default function AppSidebar({ basePath = '', requestUri = '' }: AppSideba
|
|||
<Menu
|
||||
theme={currentTheme}
|
||||
mode="inline"
|
||||
selectedKeys={[requestUri]}
|
||||
selectedKeys={[selectedKey]}
|
||||
className="sider-utility"
|
||||
items={toMenuItems(utilItems)}
|
||||
onClick={onMenuClick}
|
||||
|
|
@ -260,7 +256,7 @@ export default function AppSidebar({ basePath = '', requestUri = '' }: AppSideba
|
|||
<Menu
|
||||
theme={currentTheme}
|
||||
mode="inline"
|
||||
selectedKeys={[requestUri]}
|
||||
selectedKeys={[selectedKey]}
|
||||
className="drawer-menu drawer-nav"
|
||||
items={toMenuItems(navItems)}
|
||||
onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }}
|
||||
|
|
@ -268,7 +264,7 @@ export default function AppSidebar({ basePath = '', requestUri = '' }: AppSideba
|
|||
<Menu
|
||||
theme={currentTheme}
|
||||
mode="inline"
|
||||
selectedKeys={[requestUri]}
|
||||
selectedKeys={[selectedKey]}
|
||||
className="drawer-menu drawer-utility"
|
||||
items={toMenuItems(utilItems)}
|
||||
onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -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>,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -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>,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -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>,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -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>,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -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>,
|
||||
);
|
||||
}
|
||||
});
|
||||
8
frontend/src/layouts/PanelLayout.tsx
Normal file
8
frontend/src/layouts/PanelLayout.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
import { useWebSocketBridge } from '@/api/websocketBridge';
|
||||
|
||||
export default function PanelLayout() {
|
||||
useWebSocketBridge();
|
||||
return <Outlet />;
|
||||
}
|
||||
|
|
@ -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(
|
||||
<ThemeProvider>
|
||||
<QueryProvider>
|
||||
<ApiDocsPage />
|
||||
<RouterProvider router={router} />
|
||||
</QueryProvider>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
|
@ -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 (
|
||||
<ConfigProvider theme={antdThemeConfig}>
|
||||
<Layout className={pageClass}>
|
||||
<AppSidebar basePath={basePath} requestUri={requestUri} />
|
||||
<AppSidebar />
|
||||
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content className="content-area">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
<Layout className={pageClass}>
|
||||
<AppSidebar basePath={basePath} requestUri={requestUri} />
|
||||
<AppSidebar />
|
||||
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content id="content-layout" className="content-area">
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<ConfigProvider theme={antdThemeConfig}>
|
||||
{messageContextHolder}
|
||||
{modalContextHolder}
|
||||
<Layout className={`inbounds-page${isDark ? ' is-dark' : ''}${isUltra ? ' is-ultra' : ''}`}>
|
||||
<AppSidebar basePath={basePath} requestUri={requestUri} />
|
||||
<AppSidebar />
|
||||
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content id="content-layout" className="content-area">
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<ConfigProvider theme={antdThemeConfig}>
|
||||
{messageContextHolder}
|
||||
<Layout className={pageClass}>
|
||||
<AppSidebar basePath={basePath} requestUri={requestUri} />
|
||||
<AppSidebar />
|
||||
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content className="content-area">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
<Layout className={pageClass}>
|
||||
<AppSidebar basePath={basePath} requestUri={requestUri} />
|
||||
<AppSidebar />
|
||||
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content id="content-layout" className="content-area">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
<Layout className={pageClass}>
|
||||
<AppSidebar basePath={basePath} requestUri={requestUri} />
|
||||
<AppSidebar />
|
||||
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content id="content-layout" className="content-area">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
<Layout className={pageClass}>
|
||||
<AppSidebar basePath={basePath} requestUri={requestUri} />
|
||||
<AppSidebar />
|
||||
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content id="content-layout" className="content-area">
|
||||
|
|
|
|||
42
frontend/src/routes.tsx
Normal file
42
frontend/src/routes.tsx
Normal 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(),
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 <meta name="csrf-token">,
|
||||
// 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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue