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>
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -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 { 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); }}
|
||||||
|
|
|
||||||
|
|
@ -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 { 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>,
|
||||||
);
|
);
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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
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';
|
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';
|
||||||
|
|
|
||||||
|
|
@ -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.
|
// 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.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue