Skip to content

Commit dfbc228

Browse files
committed
feat: add mihomo local proxy integration
1 parent f8da68f commit dfbc228

28 files changed

Lines changed: 2113 additions & 522 deletions

File tree

plugins/layouts/app-layout/src/components/app-sidebar.tsx

Lines changed: 113 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
2727
import { ScrollArea } from '@/components/ui/scroll-area';
2828
import { useTranslation } from 'react-i18next';
2929
import { FreeQuotaUsage } from './free-quota-usage';
30-
import { getGeneralSettings } from '../../../../services/store/src';
30+
import { getGeneralSettings, useMihomoRuntimeStore } from '../../../../services/store/src';
3131
import { ClashIcon } from '../../../../pages/proxy-center/src/mihomo/clash-icon';
3232
import { MihomoConnectDialog } from '../../../../pages/proxy-center/src/mihomo/mihomo-connect-dialog';
3333
import { getMihomoStatus } from '../../../../pages/proxy-center/src/mihomo/api';
@@ -99,6 +99,8 @@ interface NavItemProps {
9999
actionIcon?: LucideIcon | IconType;
100100
actionNode?: ReactNode;
101101
actionLabel?: string;
102+
actionButtonClassName?: string;
103+
actionButtonAnimated?: boolean;
102104
onActionClick?: () => void;
103105
}
104106

@@ -120,6 +122,8 @@ const NavItem: React.FC<NavItemProps> = ({
120122
actionIcon: ActionIcon,
121123
actionNode,
122124
actionLabel,
125+
actionButtonClassName,
126+
actionButtonAnimated,
123127
onActionClick,
124128
}) => {
125129
const [collapsedActionVisible, setCollapsedActionVisible] = useState(false);
@@ -208,18 +212,20 @@ const NavItem: React.FC<NavItemProps> = ({
208212
{!collapsed && hasAction && (
209213
<Tooltip>
210214
<TooltipTrigger asChild>
211-
<button
212-
type="button"
213-
onClick={(event) => {
214-
event.preventDefault();
215-
event.stopPropagation();
216-
onActionClick();
217-
}}
218-
className="absolute right-1 top-1/2 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-md border border-border/60 bg-background/60 text-sidebar-foreground/80 transition-colors hover:bg-accent hover:text-foreground"
219-
aria-label={actionLabel || label}
220-
>
221-
{actionNode || (ActionIcon ? <ActionIcon className="h-3.5 w-3.5" /> : null)}
222-
</button>
215+
<div className="absolute right-1 top-1/2 -translate-y-1/2">
216+
<button
217+
type="button"
218+
onClick={(event) => {
219+
event.preventDefault();
220+
event.stopPropagation();
221+
onActionClick();
222+
}}
223+
className={`flex h-8 w-8 items-center justify-center rounded-md border bg-background/60 transition-colors ${actionButtonAnimated ? 'animate-[sidebar-proxy-action-drift_10s_ease-in-out_infinite]' : ''} ${actionButtonClassName || 'border-border/60 text-sidebar-foreground/80 hover:bg-accent hover:text-foreground'}`}
224+
aria-label={actionLabel || label}
225+
>
226+
{actionNode || (ActionIcon ? <ActionIcon className="h-3.5 w-3.5" /> : null)}
227+
</button>
228+
</div>
223229
</TooltipTrigger>
224230
<TooltipContent side="right">{actionLabel || label}</TooltipContent>
225231
</Tooltip>
@@ -238,9 +244,9 @@ const NavItem: React.FC<NavItemProps> = ({
238244
className={`absolute inset-0 z-10 flex h-11 w-11 items-center justify-center rounded-lg border transform-gpu transition-all duration-200 ${collapsedActionVisible
239245
? 'pointer-events-auto translate-x-0 opacity-100'
240246
: 'pointer-events-none translate-x-2 opacity-0'
241-
} ${isActive
247+
} ${actionButtonClassName || (isActive
242248
? 'border-primary/40 bg-primary/10 text-primary'
243-
: 'border-border/60 bg-background/90 text-sidebar-foreground/80 hover:bg-accent hover:text-foreground'
249+
: 'border-border/60 bg-background/90 text-sidebar-foreground/80 hover:bg-accent hover:text-foreground')
244250
}`}
245251
aria-label={actionLabel || label}
246252
>
@@ -262,6 +268,9 @@ interface NavGroupComponentProps {
262268
collapsed: boolean;
263269
currentPath: string;
264270
proxyActionLabel: string;
271+
proxyActionNode: ReactNode;
272+
proxyActionButtonClassName?: string;
273+
proxyActionButtonAnimated?: boolean;
265274
onProxyActionClick: () => void;
266275
}
267276

@@ -273,6 +282,9 @@ const NavGroupComponent: React.FC<NavGroupComponentProps> = ({
273282
collapsed,
274283
currentPath,
275284
proxyActionLabel,
285+
proxyActionNode,
286+
proxyActionButtonClassName,
287+
proxyActionButtonAnimated,
276288
onProxyActionClick,
277289
}) => {
278290
return (
@@ -290,8 +302,10 @@ const NavGroupComponent: React.FC<NavGroupComponentProps> = ({
290302
icon={item.icon}
291303
isActive={isRouteActive(currentPath, item.href)}
292304
collapsed={collapsed}
293-
actionNode={item.href === '/proxy' ? <ClashIcon className="h-4 w-4" /> : undefined}
305+
actionNode={item.href === '/proxy' ? proxyActionNode : undefined}
294306
actionLabel={item.href === '/proxy' ? proxyActionLabel : undefined}
307+
actionButtonClassName={item.href === '/proxy' ? proxyActionButtonClassName : undefined}
308+
actionButtonAnimated={item.href === '/proxy' ? proxyActionButtonAnimated : undefined}
295309
onActionClick={item.href === '/proxy' ? onProxyActionClick : undefined}
296310
/>
297311
))}
@@ -304,6 +318,9 @@ interface NavListProps {
304318
collapsed: boolean;
305319
currentPath: string;
306320
proxyActionLabel: string;
321+
proxyActionNode: ReactNode;
322+
proxyActionButtonClassName?: string;
323+
proxyActionButtonAnimated?: boolean;
307324
onProxyActionClick: () => void;
308325
}
309326

@@ -315,6 +332,9 @@ const NavList: React.FC<NavListProps> = ({
315332
collapsed,
316333
currentPath,
317334
proxyActionLabel,
335+
proxyActionNode,
336+
proxyActionButtonClassName,
337+
proxyActionButtonAnimated,
318338
onProxyActionClick,
319339
}) => {
320340
return (
@@ -329,6 +349,9 @@ const NavList: React.FC<NavListProps> = ({
329349
collapsed={collapsed}
330350
currentPath={currentPath}
331351
proxyActionLabel={proxyActionLabel}
352+
proxyActionNode={proxyActionNode}
353+
proxyActionButtonClassName={proxyActionButtonClassName}
354+
proxyActionButtonAnimated={proxyActionButtonAnimated}
332355
onProxyActionClick={onProxyActionClick}
333356
/>
334357
</div>
@@ -550,6 +573,21 @@ export const AppSidebar: React.FC = () => {
550573
const [settingsLoaded, setSettingsLoaded] = useState(false);
551574
const [mihomoDialogOpen, setMihomoDialogOpen] = useState(false);
552575
const [mihomoAttached, setMihomoAttached] = useState(false);
576+
const mihomoRunning = useMihomoRuntimeStore((state) => state.running);
577+
const mihomoCheckedAt = useMihomoRuntimeStore((state) => state.checkedAt);
578+
579+
const refreshMihomoAttached = useRef(async (cancelledRef?: { current: boolean }) => {
580+
try {
581+
const status = await getMihomoStatus();
582+
if (!cancelledRef?.current) {
583+
setMihomoAttached(status.attached);
584+
}
585+
} catch {
586+
if (!cancelledRef?.current) {
587+
setMihomoAttached(false);
588+
}
589+
}
590+
});
553591

554592
useEffect(() => {
555593
let cancelled = false;
@@ -573,26 +611,28 @@ export const AppSidebar: React.FC = () => {
573611
}, []);
574612

575613
useEffect(() => {
576-
let cancelled = false;
614+
const cancelledRef = { current: false };
577615

578-
void getMihomoStatus()
579-
.then((status) => {
580-
if (!cancelled) {
581-
setMihomoAttached(status.attached);
582-
}
583-
})
584-
.catch(() => {
585-
if (!cancelled) {
586-
setMihomoAttached(false);
587-
}
588-
});
616+
if (!mihomoRunning) {
617+
setMihomoAttached(false);
618+
return () => {
619+
cancelledRef.current = true;
620+
};
621+
}
622+
623+
void refreshMihomoAttached.current(cancelledRef);
589624

590625
return () => {
591-
cancelled = true;
626+
cancelledRef.current = true;
592627
};
593-
}, []);
628+
}, [location.pathname, mihomoRunning]);
594629

595630
const handleOpenMihomo = () => {
631+
if (!mihomoRunning) {
632+
navigate('/proxy?mode=local');
633+
return;
634+
}
635+
596636
if (mihomoAttached) {
597637
navigate('/proxy/mihomo');
598638
return;
@@ -614,6 +654,33 @@ export const AppSidebar: React.FC = () => {
614654
return <SidebarPlaceholder collapsed={collapsed} />;
615655
}
616656

657+
const mihomoStatusTone = mihomoRunning
658+
? mihomoAttached
659+
? 'connected'
660+
: 'warning'
661+
: mihomoCheckedAt == null
662+
? 'unknown'
663+
: 'offline';
664+
665+
const mihomoActionNode = (
666+
<span className="relative flex items-center justify-center">
667+
<ClashIcon className="h-4 w-4 text-current" />
668+
{mihomoStatusTone === 'connected' ? (
669+
<span className="absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full border border-background bg-emerald-500" />
670+
) : mihomoStatusTone === 'warning' ? (
671+
<span className="absolute -right-1 -top-1 flex h-3.5 w-3.5 items-center justify-center rounded-full border border-background bg-amber-500 text-[9px] font-bold leading-none text-white">
672+
!
673+
</span>
674+
) : mihomoStatusTone === 'unknown' ? (
675+
<span className="absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full border border-background bg-muted-foreground/60" />
676+
) : (
677+
<span className="absolute -right-1 -top-1 flex h-3.5 w-3.5 items-center justify-center rounded-full border border-background bg-rose-500 text-[9px] font-bold leading-none text-white">
678+
!
679+
</span>
680+
)}
681+
</span>
682+
);
683+
617684
return (
618685
<aside
619686
className={`flex flex-col h-full shrink-0 transition-[width] duration-300 ease-out relative before:absolute before:inset-0 before:bg-background/10 before:backdrop-blur-2xl before:-z-10 px-1 ${collapsed ? 'w-16' : 'w-58'
@@ -628,13 +695,22 @@ export const AppSidebar: React.FC = () => {
628695
</div>
629696

630697
{/* 导航列表区域 */}
631-
<NavList
632-
groups={localizeNavGroups(t)}
633-
collapsed={collapsed}
634-
currentPath={location.pathname}
635-
proxyActionLabel={t('nav.item.mihomo', { defaultValue: 'Mihomo' })}
636-
onProxyActionClick={handleOpenMihomo}
637-
/>
698+
<NavList
699+
groups={localizeNavGroups(t)}
700+
collapsed={collapsed}
701+
currentPath={location.pathname}
702+
proxyActionLabel={t('nav.item.mihomo', { defaultValue: 'Mihomo' })}
703+
proxyActionNode={mihomoActionNode}
704+
proxyActionButtonClassName={
705+
mihomoStatusTone === 'connected'
706+
? 'border-emerald-500/30 bg-background/90 text-emerald-600 hover:bg-muted hover:text-foreground'
707+
: mihomoStatusTone === 'warning'
708+
? 'border-amber-500/30 bg-background/90 text-amber-600 hover:bg-muted hover:text-foreground'
709+
: 'border-rose-500/30 bg-background/90 text-rose-600 hover:bg-muted hover:text-rose-600'
710+
}
711+
proxyActionButtonAnimated={!collapsed}
712+
onProxyActionClick={handleOpenMihomo}
713+
/>
638714

639715
{/* 底部导航项(指纹审计、系统设置) */}
640716
<div className="shrink-0">

plugins/layouts/app-layout/src/index.tsx

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import { useEffect } from 'react';
22
import { Outlet } from 'react-router';
33
import { AppSidebar } from './components/app-sidebar';
44
import AppTitlebar from './components/titlebar';
5-
import { AppBackground } from './components/app-background';
6-
import { DownloadEventSubscriber } from './components/download-event-subscriber';
7-
import { useMessagesStore } from './stores/messages-store';
8-
import './styles.css';
5+
import { AppBackground } from './components/app-background';
6+
import { DownloadEventSubscriber } from './components/download-event-subscriber';
7+
import { useMessagesStore } from './stores/messages-store';
8+
import { MihomoRuntimeSubscriber } from '../../../services/store/src';
9+
import './styles.css';
910
import { extensionRegistry } from '@slotkitjs/core';
1011
import { appLayoutResources } from './i18n/resources';
1112

@@ -17,10 +18,11 @@ const AppLayoutPlugin: React.FC = () => {
1718
}, []);
1819

1920
return (
20-
<div className="relative flex flex-col h-screen w-full min-w-0 overflow-hidden">
21-
<DownloadEventSubscriber />
22-
{/* 背景层 */}
23-
<AppBackground />
21+
<div className="relative flex flex-col h-screen w-full min-w-0 overflow-hidden">
22+
<DownloadEventSubscriber />
23+
<MihomoRuntimeSubscriber />
24+
{/* 背景层 */}
25+
<AppBackground />
2426

2527
{/* 内容层 */}
2628
<div className="relative z-0 flex flex-col h-full w-full min-w-0 overflow-hidden">

plugins/layouts/app-layout/src/styles.css

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -36,24 +36,44 @@
3636
}
3737
}
3838

39-
@keyframes slideInRight {
40-
from {
41-
opacity: 0.9;
42-
transform: translateX(-100%);
43-
}
39+
@keyframes slideInRight {
40+
from {
41+
opacity: 0.9;
42+
transform: translateX(-100%);
43+
}
4444

4545
to {
4646
opacity: 1;
4747
transform: translateX(0);
48-
}
49-
}
50-
51-
/* 对话框遮罩不覆盖 titlebar */
52-
[data-slot="dialog-overlay"] {
53-
top: 48px !important;
54-
}
48+
}
49+
}
50+
51+
@keyframes sidebar-proxy-action-drift {
52+
0%,
53+
12% {
54+
opacity: 0;
55+
transform: translate3d(18px, 0, 0);
56+
}
57+
58+
20%,
59+
72% {
60+
opacity: 1;
61+
transform: translate3d(0, 0, 0);
62+
}
63+
64+
80%,
65+
100% {
66+
opacity: 0;
67+
transform: translate3d(18px, 0, 0);
68+
}
69+
}
70+
71+
/* 对话框遮罩不覆盖 titlebar */
72+
[data-slot="dialog-overlay"] {
73+
top: 48px !important;
74+
}
5575

5676
/* 对话框遮罩不覆盖 titlebar */
5777
[data-slot="extended-dialog-overlay"] {
5878
top: 48px !important;
59-
}
79+
}

0 commit comments

Comments
 (0)