Files
3x-ui-agent/frontend/src/layouts/AppSidebar.tsx
T
Muse d46035708e feat(ui): 第22条 改名 L-UI + 第14/21条 + 第18/19/20条前端
- 第22条 L-UI:登录页品牌名、侧栏 brand-text(收起态 L)、抽屉品牌、
  usePageTitle 默认标题(HTML 标题)、13 个语言文件里用户可见串中的
  3X-UI 字样(更新说明×2、TG 版本行)一并改为 L-UI;仓库/二进制/
  服务名/路径/内部包名不动
- 第18条:formatPanelVersion 对 V/A 系显示版本原样返回(新增单测);
  PanelUpdateModal 与首页动作条改用 display* 字段判更新与显示
- 第14条:首页运行时间卡片删除 Xray 半边与分隔线,只留 OS
- 第21条:节点表列宽 操作190→150/已启用80→64/客户端180→150/
  延迟100→84/心跳120→96、地址列 330;agent 行 IPv4·IPv6 单行
  (浅点分隔、沿用可见性 class),传统行不变
- 第20条:agent 行云朵按钮与移动端菜单改用「更新 Agent」文案;
  NodesPage 对 agent 行用无渠道选择的简单确认(传统行不变);
  schemas 增 agentUpdateState/Error/Target
- 第19条:节点编辑页(agent)拉 /panel/api/agent/install/:id 展示
  完整安装命令(master 可编辑、含 token 时完整命令+复制按钮);
  无 token 时展示无 token 命令+agent.json 路径提示,不报错不空白
- i18n 新增 4 键(updateAgent/updateAgentConfirmContent/
  agentInstallCommand/agentTokenNotStored)补齐全部 13 个语言文件
  (zh-CN 中文、其余英文),tsc 干净、panel-version 测试全过、
  i18n 失败仍为既有 forwarding 键集问题(第10条)零新增
2026-10-22 13:30:57 +00:00

487 lines
14 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ComponentType, CSSProperties } from 'react';
import { useLocation, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { Drawer, Layout, Menu } from 'antd';
import type { MenuProps } from 'antd';
import {
ApiOutlined,
ApartmentOutlined,
CloseOutlined,
CloudServerOutlined,
ClusterOutlined,
CodeOutlined,
DashboardOutlined,
ExportOutlined,
GithubOutlined,
GlobalOutlined,
HeartOutlined,
ImportOutlined,
LogoutOutlined,
MenuOutlined,
MessageOutlined,
MoonFilled,
MoonOutlined,
PushpinFilled,
PushpinOutlined,
ReadOutlined,
SafetyOutlined,
SettingOutlined,
SunOutlined,
SwapOutlined,
TagsOutlined,
TeamOutlined,
ToolOutlined,
} from '@ant-design/icons';
import { HttpUtil } from '@/utils';
import { formatPanelVersion } from '@/lib/panel-version';
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
import { useAllSettings } from '@/api/queries/useAllSettings';
import './AppSidebar.css';
const DONATE_URL = 'https://donate.sanaei.dev/';
const DOCS_URL = 'https://docs.sanaei.dev/';
const REPO_URL = 'https://git.881218.xyz/rogue/3x-ui-agent';
const LOGOUT_KEY = '__logout__';
const RAIL_WIDTH = 72;
const SIDER_WIDTH = 220;
const SIDEBAR_PINNED_KEY = 'sidebar-pinned';
let hoveredAcrossRemounts = false;
type IconName =
| 'dashboard'
| 'inbound'
| 'team'
| 'groups'
| 'setting'
| 'tool'
| 'cluster'
| 'hosts'
| 'logout'
| 'apidocs'
| 'outbound'
| 'routing';
const iconByName: Record<IconName, ComponentType> = {
dashboard: DashboardOutlined,
inbound: ImportOutlined,
team: TeamOutlined,
groups: TagsOutlined,
setting: SettingOutlined,
tool: ToolOutlined,
cluster: ClusterOutlined,
hosts: GlobalOutlined,
logout: LogoutOutlined,
apidocs: ApiOutlined,
outbound: ExportOutlined,
routing: SwapOutlined,
};
function DonateButton({ ariaLabel }: { ariaLabel: string }) {
return (
<a
href={DONATE_URL}
target="_blank"
rel="noopener noreferrer"
className="sidebar-donate"
aria-label={ariaLabel}
title={ariaLabel}
>
<HeartOutlined />
</a>
);
}
function DocsButton({ ariaLabel }: { ariaLabel: string }) {
return (
<a
href={DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="sidebar-docs"
aria-label={ariaLabel}
title={ariaLabel}
>
<ReadOutlined />
</a>
);
}
function VersionBadge({ version, collapsed }: { version: string; collapsed?: boolean }) {
if (!version) return null;
const label = formatPanelVersion(version);
return (
<a
href={REPO_URL}
target="_blank"
rel="noopener noreferrer"
className="sider-version"
aria-label={`GitHub ${label}`}
title={label}
>
<GithubOutlined />
{!collapsed && <span className="sider-version-text">{label}</span>}
</a>
);
}
function ThemeCycleButton({
id,
isDark,
isUltra,
onCycle,
ariaLabel,
}: {
id: string;
isDark: boolean;
isUltra: boolean;
onCycle: () => void;
ariaLabel: string;
}) {
const icon = !isDark ? <SunOutlined /> : !isUltra ? <MoonOutlined /> : <MoonFilled />;
return (
<button
id={id}
type="button"
className="sidebar-theme-cycle"
aria-label={ariaLabel}
title={ariaLabel}
onClick={onCycle}
>
{icon}
</button>
);
}
function readSidebarPinned() {
try {
return localStorage.getItem(SIDEBAR_PINNED_KEY) === 'true';
} catch {
return false;
}
}
function saveSidebarPinned(pinned: boolean) {
try {
localStorage.setItem(SIDEBAR_PINNED_KEY, String(pinned));
} catch {}
}
export default function AppSidebar() {
const { t } = useTranslation();
const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
const navigate = useNavigate();
const { pathname, hash } = useLocation();
const { allSetting } = useAllSettings();
const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable);
const showSubBalancers = !!allSetting.subJsonEnable;
const [hovered, setHovered] = useState(() => hoveredAcrossRemounts);
const [pinned, setPinned] = useState(readSidebarPinned);
const [drawerOpen, setDrawerOpen] = useState(false);
const railCollapsed = !hovered && !pinned;
const railStyle = useMemo(
() => ({ '--sider-rail': `${pinned ? SIDER_WIDTH : RAIL_WIDTH}px` }) as CSSProperties,
[pinned],
);
const rootRef = useRef<HTMLDivElement>(null);
const updateHovered = useCallback((value: boolean) => {
hoveredAcrossRemounts = value;
setHovered(value);
}, []);
const togglePinned = useCallback(() => {
const next = !pinned;
saveSidebarPinned(next);
setPinned(next);
}, [pinned]);
useEffect(() => {
const timer = window.setTimeout(() => {
const el = rootRef.current;
if (el) updateHovered(el.matches(':hover'));
}, 150);
return () => window.clearTimeout(timer);
}, [updateHovered]);
const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light';
const panelVersion = window.X_UI_CUR_VER || '';
const tabs = useMemo<{ key: string; icon: IconName; title: string }[]>(
() => [
{ key: '/', icon: 'dashboard', title: t('menu.dashboard') },
{ key: '/inbounds', icon: 'inbound', title: t('menu.inbounds') },
{ key: '/clients', icon: 'team', title: t('menu.clients') },
{ key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
{ key: '/hosts', icon: 'hosts', title: t('menu.hosts') },
{ key: '/outbound', icon: 'outbound', title: t('menu.outbounds') },
{ key: '/routing', icon: 'routing', title: t('menu.routing') },
{ key: '/settings', icon: 'setting', title: t('menu.settings') },
{ key: '/xray', icon: 'tool', title: t('menu.xray') },
{ 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 settingsChildren = useMemo<NonNullable<MenuProps['items']>>(() => {
const children: NonNullable<MenuProps['items']> = [
{
key: '/settings#general',
icon: <SettingOutlined />,
label: t('pages.settings.panelSettings'),
},
{
key: '/settings#security',
icon: <SafetyOutlined />,
label: t('pages.settings.securitySettings'),
},
{
key: '/settings#telegram',
icon: <MessageOutlined />,
label: t('pages.settings.TGBotSettings'),
},
{
key: '/settings#subscription',
icon: <CloudServerOutlined />,
label: t('pages.settings.subSettings'),
},
];
if (showSubFormats) {
children.push({
key: '/settings#subscription-formats',
icon: <CodeOutlined />,
label: t('menu.subFormats'),
});
}
if (showSubBalancers) {
children.push({
key: '/settings#subscription-balancers',
icon: <ApartmentOutlined />,
label: t('pages.settings.subBalancers.menu'),
});
}
return children;
}, [t, showSubFormats, showSubBalancers]);
const xrayChildren = useMemo<NonNullable<MenuProps['items']>>(
() => [{ key: '/xray#basic', icon: <SettingOutlined />, label: t('pages.xray.basicTemplate') }],
[t],
);
const settingsActive = pathname === '/settings';
const xrayActive = pathname === '/xray';
const selectedKey = settingsActive
? `/settings${hash || '#general'}`
: xrayActive
? `/xray${hash || '#basic'}`
: pathname === ''
? '/'
: pathname;
const openSubmenu = settingsActive ? '/settings' : xrayActive ? '/xray' : null;
const [openKeys, setOpenKeys] = useState<string[]>(() => (openSubmenu ? [openSubmenu] : []));
if (openSubmenu && !openKeys.includes(openSubmenu)) {
setOpenKeys([...openKeys, openSubmenu]);
}
const toMenuItems = useCallback(
(items: typeof tabs): MenuProps['items'] =>
items.map((tab) => {
const Icon = iconByName[tab.icon];
if (tab.key === '/settings') {
return { key: tab.key, icon: <Icon />, label: tab.title, children: settingsChildren };
}
if (tab.key === '/xray') {
return { key: tab.key, icon: <Icon />, label: tab.title, children: xrayChildren };
}
return { key: tab.key, icon: <Icon />, label: tab.title, title: '' };
}),
[settingsChildren, xrayChildren],
);
const openLink = useCallback(
async (key: string) => {
if (key === LOGOUT_KEY) {
await HttpUtil.post('/logout');
window.location.href = window.X_UI_BASE_PATH || '/';
return;
}
navigate(key);
},
[navigate],
);
const onMenuClick = useCallback<NonNullable<MenuProps['onClick']>>(
({ key }) => {
openLink(String(key));
},
[openLink],
);
const cycleTheme = useCallback(
(id: string) => {
pauseAnimationsUntilLeave(id);
if (!isDark) {
toggleTheme();
if (isUltra) toggleUltra();
} else if (!isUltra) {
toggleUltra();
} else {
toggleUltra();
toggleTheme();
}
},
[isDark, isUltra, toggleTheme, toggleUltra],
);
return (
<div
ref={rootRef}
className={`ant-sidebar${pinned ? ' sidebar-pinned' : ''}`}
style={railStyle}
onMouseEnter={() => updateHovered(true)}
onMouseLeave={() => updateHovered(false)}
>
<Layout.Sider
theme={currentTheme}
width={SIDER_WIDTH}
collapsedWidth={RAIL_WIDTH}
collapsed={railCollapsed}
>
<div className="sider-brand">
<div className="brand-block">
<span className="brand-text">{railCollapsed ? 'L' : 'L-UI'}</span>
</div>
{!railCollapsed && (
<div className="brand-actions">
<button
type="button"
className="sidebar-pin"
aria-label={t('menu.pinSidebar')}
aria-pressed={pinned}
title={t(pinned ? 'menu.unpinSidebar' : 'menu.pinSidebar')}
onClick={togglePinned}
>
{pinned ? <PushpinFilled /> : <PushpinOutlined />}
</button>
<DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
<ThemeCycleButton
id="theme-cycle"
isDark={isDark}
isUltra={isUltra}
onCycle={() => cycleTheme('theme-cycle')}
ariaLabel={t('menu.theme')}
/>
</div>
)}
</div>
<Menu
theme={currentTheme}
mode="inline"
selectedKeys={[selectedKey]}
openKeys={railCollapsed ? undefined : openKeys}
onOpenChange={(keys) => setOpenKeys(keys as string[])}
className="sider-nav"
items={toMenuItems(navItems)}
onClick={onMenuClick}
/>
<Menu
theme={currentTheme}
mode="inline"
selectedKeys={[selectedKey]}
className="sider-utility"
items={toMenuItems(utilItems)}
onClick={onMenuClick}
/>
<div className="sider-footer">
<VersionBadge version={panelVersion} collapsed={railCollapsed} />
</div>
</Layout.Sider>
<Drawer
placement="left"
closable={false}
open={drawerOpen}
rootClassName={currentTheme}
size="min(82vw, 320px)"
styles={{
wrapper: { padding: 0 },
body: { padding: 0, display: 'flex', flexDirection: 'column', height: '100%' },
header: { display: 'none' },
}}
onClose={() => setDrawerOpen(false)}
>
<div className="drawer-header">
<div className="brand-block">
<span className="drawer-brand">L-UI</span>
</div>
<div className="drawer-header-actions">
<DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
<ThemeCycleButton
id="theme-cycle-drawer"
isDark={isDark}
isUltra={isUltra}
onCycle={() => cycleTheme('theme-cycle-drawer')}
ariaLabel={t('menu.theme')}
/>
<button
className="drawer-close"
type="button"
aria-label={t('close')}
onClick={() => setDrawerOpen(false)}
>
<CloseOutlined />
</button>
</div>
</div>
<Menu
theme={currentTheme}
mode="inline"
selectedKeys={[selectedKey]}
openKeys={openKeys}
onOpenChange={(keys) => setOpenKeys(keys as string[])}
className="drawer-menu drawer-nav"
items={toMenuItems(navItems)}
onClick={(info) => {
onMenuClick(info);
setDrawerOpen(false);
}}
/>
<Menu
theme={currentTheme}
mode="inline"
selectedKeys={[selectedKey]}
className="drawer-menu drawer-utility"
items={toMenuItems(utilItems)}
onClick={(info) => {
onMenuClick(info);
setDrawerOpen(false);
}}
/>
<div className="drawer-footer">
<VersionBadge version={panelVersion} />
</div>
</Drawer>
{!drawerOpen && (
<button
className="drawer-handle"
type="button"
aria-label={t('menu.openMenu')}
onClick={() => setDrawerOpen(true)}
>
<MenuOutlined />
</button>
)}
</div>
);
}