feat(ui): add left menu lock/unlock with reorder mode and context menu (#5168)

* feat: free menu

* feat(ui): add left menu lock/unlock with reorder mode and context menu

* docs: UPDATELOG.md
This commit is contained in:
Sline
2025-10-22 21:39:12 +08:00
committed by GitHub
parent 3bedf7ec35
commit 4f7d069f19
8 changed files with 376 additions and 28 deletions

View File

@@ -13,6 +13,7 @@
- 主界面“当前节点”卡片新增自动延迟检测开关(默认关闭) - 主界面“当前节点”卡片新增自动延迟检测开关(默认关闭)
- 允许独立控制订阅自动更新 - 允许独立控制订阅自动更新
- 托盘 `更多` 中新增 `关闭所有连接` 按钮 - 托盘 `更多` 中新增 `关闭所有连接` 按钮
- 新增左侧菜单栏的排序功能(右键点击左侧菜单栏)
### 🚀 优化改进 ### 🚀 优化改进

View File

@@ -57,6 +57,9 @@ pub struct IVerge {
/// menu icon /// menu icon
pub menu_icon: Option<String>, pub menu_icon: Option<String>,
/// menu order
pub menu_order: Option<Vec<String>>,
/// sysproxy tray icon /// sysproxy tray icon
pub sysproxy_tray_icon: Option<bool>, pub sysproxy_tray_icon: Option<bool>,
@@ -456,6 +459,7 @@ impl IVerge {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
patch!(tray_icon); patch!(tray_icon);
patch!(menu_icon); patch!(menu_icon);
patch!(menu_order);
patch!(common_tray_icon); patch!(common_tray_icon);
patch!(sysproxy_tray_icon); patch!(sysproxy_tray_icon);
patch!(tun_tray_icon); patch!(tun_tray_icon);
@@ -554,6 +558,7 @@ pub struct IVergeResponse {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
pub tray_icon: Option<String>, pub tray_icon: Option<String>,
pub menu_icon: Option<String>, pub menu_icon: Option<String>,
pub menu_order: Option<Vec<String>>,
pub sysproxy_tray_icon: Option<bool>, pub sysproxy_tray_icon: Option<bool>,
pub tun_tray_icon: Option<bool>, pub tun_tray_icon: Option<bool>,
pub enable_tun_mode: Option<bool>, pub enable_tun_mode: Option<bool>,
@@ -629,6 +634,7 @@ impl From<IVerge> for IVergeResponse {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
tray_icon: verge.tray_icon, tray_icon: verge.tray_icon,
menu_icon: verge.menu_icon, menu_icon: verge.menu_icon,
menu_order: verge.menu_order,
sysproxy_tray_icon: verge.sysproxy_tray_icon, sysproxy_tray_icon: verge.sysproxy_tray_icon,
tun_tray_icon: verge.tun_tray_icon, tun_tray_icon: verge.tun_tray_icon,
enable_tun_mode: verge.enable_tun_mode, enable_tun_mode: verge.enable_tun_mode,

View File

@@ -1,3 +1,7 @@
import type {
DraggableAttributes,
DraggableSyntheticListeners,
} from "@dnd-kit/core";
import { import {
alpha, alpha,
ListItem, ListItem,
@@ -5,24 +9,46 @@ import {
ListItemText, ListItemText,
ListItemIcon, ListItemIcon,
} from "@mui/material"; } from "@mui/material";
import type { CSSProperties, ReactNode } from "react";
import { useMatch, useResolvedPath, useNavigate } from "react-router"; import { useMatch, useResolvedPath, useNavigate } from "react-router";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
interface SortableProps {
setNodeRef?: (element: HTMLElement | null) => void;
attributes?: DraggableAttributes;
listeners?: DraggableSyntheticListeners;
style?: CSSProperties;
isDragging?: boolean;
disabled?: boolean;
}
interface Props { interface Props {
to: string; to: string;
children: string; children: string;
icon: React.ReactNode[]; icon: ReactNode[];
sortable?: SortableProps;
} }
export const LayoutItem = (props: Props) => { export const LayoutItem = (props: Props) => {
const { to, children, icon } = props; const { to, children, icon, sortable } = props;
const { verge } = useVerge(); const { verge } = useVerge();
const { menu_icon } = verge ?? {}; const { menu_icon } = verge ?? {};
const resolved = useResolvedPath(to); const resolved = useResolvedPath(to);
const match = useMatch({ path: resolved.pathname, end: true }); const match = useMatch({ path: resolved.pathname, end: true });
const navigate = useNavigate(); const navigate = useNavigate();
const { setNodeRef, attributes, listeners, style, isDragging } =
sortable ?? {};
return ( return (
<ListItem sx={{ py: 0.5, maxWidth: 250, mx: "auto", padding: "4px 0px" }}> <ListItem
ref={setNodeRef}
style={style}
sx={[
{ py: 0.5, maxWidth: 250, mx: "auto", padding: "4px 0px" },
isDragging ? { opacity: 0.78 } : {},
]}
>
<ListItemButton <ListItemButton
selected={!!match} selected={!!match}
sx={[ sx={[
@@ -32,6 +58,7 @@ export const LayoutItem = (props: Props) => {
paddingLeft: 1, paddingLeft: 1,
paddingRight: 1, paddingRight: 1,
marginRight: 1.25, marginRight: 1.25,
cursor: sortable && !sortable.disabled ? "grab" : "pointer",
"& .MuiListItemText-primary": { "& .MuiListItemText-primary": {
color: "text.primary", color: "text.primary",
fontWeight: "700", fontWeight: "700",
@@ -52,6 +79,8 @@ export const LayoutItem = (props: Props) => {
}, },
]} ]}
onClick={() => navigate(to)} onClick={() => navigate(to)}
{...(attributes ?? {})}
{...(listeners ?? {})}
> >
{(menu_icon === "monochrome" || !menu_icon) && ( {(menu_icon === "monochrome" || !menu_icon) && (
<ListItemIcon sx={{ color: "text.primary", marginLeft: "6px" }}> <ListItemIcon sx={{ color: "text.primary", marginLeft: "6px" }}>

View File

@@ -708,5 +708,8 @@
"Prefer System Titlebar": "Prefer System Titlebar", "Prefer System Titlebar": "Prefer System Titlebar",
"App Log Max Size": "App Log Max Size", "App Log Max Size": "App Log Max Size",
"App Log Max Count": "App Log Max Count", "App Log Max Count": "App Log Max Count",
"Allow Auto Update": "Allow Auto Update" "Allow Auto Update": "Allow Auto Update",
"Menu reorder mode": "Menu reorder mode",
"Unlock menu order": "Unlock menu order",
"Lock menu order": "Lock menu order"
} }

View File

@@ -708,5 +708,8 @@
"Prefer System Titlebar": "优先使用系统标题栏", "Prefer System Titlebar": "优先使用系统标题栏",
"App Log Max Size": "应用日志最大大小", "App Log Max Size": "应用日志最大大小",
"App Log Max Count": "应用日志最大数量", "App Log Max Count": "应用日志最大数量",
"Allow Auto Update": "允许自动更新" "Allow Auto Update": "允许自动更新",
"Menu reorder mode": "菜单排序模式",
"Unlock menu order": "解锁菜单排序",
"Lock menu order": "锁定菜单排序"
} }

View File

@@ -1,7 +1,39 @@
import { List, Paper, SvgIcon, ThemeProvider } from "@mui/material"; import {
DndContext,
DragEndEvent,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
Box,
List,
Menu,
MenuItem,
Paper,
SvgIcon,
ThemeProvider,
} from "@mui/material";
import dayjs from "dayjs"; import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime"; import relativeTime from "dayjs/plugin/relativeTime";
import { useCallback, useEffect, useMemo, useRef } from "react"; import {
useCallback,
useEffect,
useMemo,
useReducer,
useRef,
useState,
} from "react";
import type { CSSProperties } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Outlet, useNavigate } from "react-router"; import { Outlet, useNavigate } from "react-router";
import { SWRConfig } from "swr"; import { SWRConfig } from "swr";
@@ -16,11 +48,7 @@ import { LayoutItem } from "@/components/layout/layout-item";
import { LayoutTraffic } from "@/components/layout/layout-traffic"; import { LayoutTraffic } from "@/components/layout/layout-traffic";
import { UpdateButton } from "@/components/layout/update-button"; import { UpdateButton } from "@/components/layout/update-button";
import { useCustomTheme } from "@/components/layout/use-custom-theme"; import { useCustomTheme } from "@/components/layout/use-custom-theme";
import { useConnectionData } from "@/hooks/use-connection-data";
import { useI18n } from "@/hooks/use-i18n"; import { useI18n } from "@/hooks/use-i18n";
import { useLogData } from "@/hooks/use-log-data";
import { useMemoryData } from "@/hooks/use-memory-data";
import { useTrafficData } from "@/hooks/use-traffic-data";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { useWindowDecorations } from "@/hooks/use-window"; import { useWindowDecorations } from "@/hooks/use-window";
import { useThemeMode } from "@/services/states"; import { useThemeMode } from "@/services/states";
@@ -37,29 +65,217 @@ import "dayjs/locale/zh-cn";
export const portableFlag = false; export const portableFlag = false;
type NavItem = (typeof navItems)[number];
const createNavLookup = (items: NavItem[]) => {
const map = new Map(items.map((item) => [item.path, item]));
const defaultOrder = items.map((item) => item.path);
return { map, defaultOrder };
};
const resolveMenuOrder = (
order: string[] | null | undefined,
defaultOrder: string[],
map: Map<string, NavItem>,
) => {
const seen = new Set<string>();
const resolved: string[] = [];
if (Array.isArray(order)) {
for (const path of order) {
if (map.has(path) && !seen.has(path)) {
resolved.push(path);
seen.add(path);
}
}
}
for (const path of defaultOrder) {
if (!seen.has(path)) {
resolved.push(path);
seen.add(path);
}
}
return resolved;
};
const areOrdersEqual = (a: string[], b: string[]) =>
a.length === b.length && a.every((value, index) => value === b[index]);
type MenuContextPosition = { top: number; left: number };
type MenuOrderAction = { type: "sync"; payload: string[] };
const menuOrderReducer = (state: string[], action: MenuOrderAction) => {
const next = action.payload;
if (areOrdersEqual(state, next)) {
return state;
}
return [...next];
};
interface SortableNavMenuItemProps {
item: NavItem;
label: string;
}
const SortableNavMenuItem = ({ item, label }: SortableNavMenuItemProps) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: item.path,
});
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
};
if (isDragging) {
style.zIndex = 100;
}
return (
<LayoutItem
to={item.path}
icon={item.icon}
sortable={{
setNodeRef,
attributes,
listeners,
style,
isDragging,
}}
>
{label}
</LayoutItem>
);
};
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
const OS = getSystem(); const OS = getSystem();
const Layout = () => { const Layout = () => {
const trafficData = useTrafficData();
const memoryData = useMemoryData();
const connectionData = useConnectionData();
const logData = useLogData();
const mode = useThemeMode(); const mode = useThemeMode();
const isDark = mode !== "light"; const isDark = mode !== "light";
const { t } = useTranslation(); const { t } = useTranslation();
const { theme } = useCustomTheme(); const { theme } = useCustomTheme();
const { verge } = useVerge(); const { verge, mutateVerge, patchVerge } = useVerge();
const { language } = verge ?? {}; const { language } = verge ?? {};
const { switchLanguage } = useI18n(); const { switchLanguage } = useI18n();
const navigate = useNavigate(); const navigate = useNavigate();
const themeReady = useMemo(() => Boolean(theme), [theme]); const themeReady = useMemo(() => Boolean(theme), [theme]);
const [menuUnlocked, setMenuUnlocked] = useState(false);
const [menuContextPosition, setMenuContextPosition] =
useState<MenuContextPosition | null>(null);
const windowControls = useRef<any>(null); const windowControls = useRef<any>(null);
const { decorated } = useWindowDecorations(); const { decorated } = useWindowDecorations();
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 6,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const { map: navItemMap, defaultOrder: defaultMenuOrder } = useMemo(
() => createNavLookup(navItems),
[],
);
const configMenuOrder = useMemo(
() => resolveMenuOrder(verge?.menu_order, defaultMenuOrder, navItemMap),
[verge?.menu_order, defaultMenuOrder, navItemMap],
);
const [menuOrder, dispatchMenuOrder] = useReducer(
menuOrderReducer,
configMenuOrder,
);
useEffect(() => {
dispatchMenuOrder({ type: "sync", payload: configMenuOrder });
}, [configMenuOrder]);
const handleMenuDragEnd = useCallback(
async (event: DragEndEvent) => {
if (!menuUnlocked) {
return;
}
const { active, over } = event;
if (!over || active.id === over.id) {
return;
}
const activeId = String(active.id);
const overId = String(over.id);
const oldIndex = menuOrder.indexOf(activeId);
const newIndex = menuOrder.indexOf(overId);
if (oldIndex === -1 || newIndex === -1) {
return;
}
const previousOrder = [...menuOrder];
const nextOrder = arrayMove(menuOrder, oldIndex, newIndex);
dispatchMenuOrder({ type: "sync", payload: nextOrder });
mutateVerge(
(prev) => (prev ? { ...prev, menu_order: nextOrder } : prev),
false,
);
try {
await patchVerge({ menu_order: nextOrder });
} catch (error) {
console.error("Failed to update menu order:", error);
dispatchMenuOrder({ type: "sync", payload: previousOrder });
mutateVerge(
(prev) => (prev ? { ...prev, menu_order: previousOrder } : prev),
false,
);
}
},
[menuUnlocked, menuOrder, mutateVerge, patchVerge],
);
const handleMenuContextMenu = useCallback(
(event: React.MouseEvent<HTMLElement>) => {
event.preventDefault();
event.stopPropagation();
setMenuContextPosition({ top: event.clientY, left: event.clientX });
},
[],
);
const handleMenuContextClose = useCallback(() => {
setMenuContextPosition(null);
}, []);
const handleUnlockMenu = useCallback(() => {
setMenuUnlocked(true);
setMenuContextPosition(null);
}, []);
const handleLockMenu = useCallback(() => {
setMenuUnlocked(false);
setMenuContextPosition(null);
}, []);
const customTitlebar = useMemo( const customTitlebar = useMemo(
() => () =>
!decorated ? ( !decorated ? (
@@ -202,17 +418,105 @@ const Layout = () => {
<UpdateButton className="the-newbtn" /> <UpdateButton className="the-newbtn" />
</div> </div>
<List className="the-menu"> {menuUnlocked && (
{navItems.map((router) => ( <Box
<LayoutItem sx={(theme) => ({
key={router.label} px: 1.5,
to={router.path} py: 0.75,
icon={router.icon} mx: "auto",
> mb: 1,
{t(router.label)} maxWidth: 250,
</LayoutItem> borderRadius: 1.5,
))} fontSize: 12,
</List> fontWeight: 600,
textAlign: "center",
color: theme.palette.warning.contrastText,
bgcolor:
theme.palette.mode === "light"
? theme.palette.warning.main
: theme.palette.warning.dark,
})}
>
{t("Menu reorder mode")}
</Box>
)}
{menuUnlocked ? (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleMenuDragEnd}
>
<SortableContext items={menuOrder}>
<List
className="the-menu"
onContextMenu={handleMenuContextMenu}
>
{menuOrder.map((path) => {
const item = navItemMap.get(path);
if (!item) {
return null;
}
return (
<SortableNavMenuItem
key={item.path}
item={item}
label={t(item.label)}
/>
);
})}
</List>
</SortableContext>
</DndContext>
) : (
<List
className="the-menu"
onContextMenu={handleMenuContextMenu}
>
{menuOrder.map((path) => {
const item = navItemMap.get(path);
if (!item) {
return null;
}
return (
<LayoutItem
key={item.path}
to={item.path}
icon={item.icon}
>
{t(item.label)}
</LayoutItem>
);
})}
</List>
)}
<Menu
open={Boolean(menuContextPosition)}
onClose={handleMenuContextClose}
anchorReference="anchorPosition"
anchorPosition={
menuContextPosition
? {
top: menuContextPosition.top,
left: menuContextPosition.left,
}
: undefined
}
transitionDuration={200}
slotProps={{
list: {
sx: { py: 0.5 },
},
}}
>
<MenuItem
onClick={menuUnlocked ? handleLockMenu : handleUnlockMenu}
dense
>
{menuUnlocked ? t("Lock menu order") : t("Unlock menu order")}
</MenuItem>
</Menu>
<div className="the-traffic"> <div className="the-traffic">
<LayoutTraffic /> <LayoutTraffic />

View File

@@ -82,6 +82,7 @@ export const AppDataProvider = ({
}; };
const addWindowListener = (eventName: string, handler: EventListener) => { const addWindowListener = (eventName: string, handler: EventListener) => {
// eslint-disable-next-line @eslint-react/web-api/no-leaked-event-listener
window.addEventListener(eventName, handler); window.addEventListener(eventName, handler);
return () => window.removeEventListener(eventName, handler); return () => window.removeEventListener(eventName, handler);
}; };

View File

@@ -797,6 +797,7 @@ interface IVergeConfig {
enable_memory_usage?: boolean; enable_memory_usage?: boolean;
enable_group_icon?: boolean; enable_group_icon?: boolean;
menu_icon?: "monochrome" | "colorful" | "disable"; menu_icon?: "monochrome" | "colorful" | "disable";
menu_order?: string[];
tray_icon?: "monochrome" | "colorful"; tray_icon?: "monochrome" | "colorful";
common_tray_icon?: boolean; common_tray_icon?: boolean;
sysproxy_tray_icon?: boolean; sysproxy_tray_icon?: boolean;