feat(windows): add system tray management and close confirmation

This commit is contained in:
lzt404 committed 2026-07-21 14:28:58 +08:00
1 parent f740d109ea
commit 43593d5149
7 files changed
+758 -77

No files matched your search

+188 -54
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import {
Bot,
ExternalLink,
@@ -85,6 +86,12 @@ const pages = [
] as const;
type PageId = (typeof pages)[number]['id'];
type WindowsCloseAction = 'exit' | 'minimize-to-tray';
type WindowsClosePrompt = {
resolvingAction: WindowsCloseAction | null;
error: string | null;
};
function App() {
return (
@@ -96,6 +103,8 @@ function App() {
function AppContent() {
const [active, setActive] = useState<PageId>('kernel');
const [windowsClosePrompt, setWindowsClosePrompt] = useState<WindowsClosePrompt | null>(null);
const closeDialogRef = useRef<HTMLElement>(null);
const { status } = useCoreRuntime();
const coreRunning = Boolean(status?.running);
const activePage = pages.find((page) => page.id === active) ?? pages[0];
@@ -107,6 +116,46 @@ function AppContent() {
}
}, [active, coreRunning]);
useEffect(() => {
let disposed = false;
let stopListening: (() => void) | undefined;
void listen('windows-close-requested', () => {
setWindowsClosePrompt((current) =>
current ?? {
resolvingAction: null,
error: null,
},
);
})
.then((stop) => {
if (disposed) {
stop();
} else {
stopListening = stop;
}
})
.catch((error) => {
console.error('监听 Windows 关闭确认事件失败', error);
});
return () => {
disposed = true;
stopListening?.();
};
}, []);
useEffect(() => {
if (!windowsClosePrompt || windowsClosePrompt.resolvingAction) {
return;
}
const frame = window.requestAnimationFrame(() => {
closeDialogRef.current?.focus();
});
return () => window.cancelAnimationFrame(frame);
}, [windowsClosePrompt]);
const select = (pageId: PageId) => {
if (pageId !== 'kernel' && !coreRunning) {
return;
@@ -122,63 +171,148 @@ function AppContent() {
}
};
const resolveWindowsCloseRequest = async (action: WindowsCloseAction) => {
setWindowsClosePrompt((current) =>
current
? {
...current,
resolvingAction: action,
error: null,
}
: current,
);
try {
await invoke('resolve_windows_close_request', { action });
setWindowsClosePrompt(null);
} catch (error) {
setWindowsClosePrompt((current) =>
current
? {
...current,
resolvingAction: null,
error: error instanceof Error ? error.message : String(error),
}
: current,
);
}
};
return (
<div className="app-shell">
<aside className="sidebar">
<div className="sidebar-brand" title="CLI Proxy API GUI">
<img src={appLogo} alt="" className="brand-mark brand-logo" />
<div>
<strong>EasyCLIProxyAPI</strong>
<span>Desktop Console</span>
<>
<div className="app-shell">
<aside className="sidebar">
<div className="sidebar-brand" title="CLI Proxy API GUI">
<img src={appLogo} alt="" className="brand-mark brand-logo" />
<div>
<strong>EasyCLIProxyAPI</strong>
<span>Desktop Console</span>
</div>
</div>
<nav className="nav-section" aria-label="主导航">
{pages.map((page) => {
const Icon = page.icon;
const locked = page.id !== 'kernel' && !coreRunning;
return (
<button
key={page.id}
type="button"
className={[page.id === active ? 'active' : '', locked ? 'locked' : '']
.filter(Boolean)
.join(' ')}
disabled={locked}
title={locked ? '请先启动内核' : undefined}
onClick={() => select(page.id)}
>
<Icon size={17} aria-hidden="true" />
<span>{page.label}</span>
</button>
);
})}
</nav>
<div className="sidebar-bottom">
<button
type="button"
className="sidebar-contact"
title="通过 QQ 联系我们"
onClick={() => void openContact()}
>
<MessageCircle size={16} aria-hidden="true" />
<span>联系我们</span>
<ExternalLink size={13} aria-hidden="true" />
</button>
</div>
</aside>
<div className="workspace">
<main className="content">
{activePage.id === 'kernel' || coreRunning ? (
<ActivePage />
) : (
<CoreLockedPage />
)}
</main>
</div>
<nav className="nav-section" aria-label="主导航">
{pages.map((page) => {
const Icon = page.icon;
const locked = page.id !== 'kernel' && !coreRunning;
return (
<button
key={page.id}
type="button"
className={[page.id === active ? 'active' : '', locked ? 'locked' : '']
.filter(Boolean)
.join(' ')}
disabled={locked}
title={locked ? '请先启动内核' : undefined}
onClick={() => select(page.id)}
>
<Icon size={17} aria-hidden="true" />
<span>{page.label}</span>
</button>
);
})}
</nav>
<div className="sidebar-bottom">
<button
type="button"
className="sidebar-contact"
title="通过 QQ 联系我们"
onClick={() => void openContact()}
>
<MessageCircle size={16} aria-hidden="true" />
<span>联系我们</span>
<ExternalLink size={13} aria-hidden="true" />
</button>
</div>
</aside>
<div className="workspace">
<main className="content">
{activePage.id === 'kernel' || coreRunning ? (
<ActivePage />
) : (
<CoreLockedPage />
)}
</main>
</div>
</div>
{windowsClosePrompt ? (
<div className="close-dialog-backdrop">
<section
ref={closeDialogRef}
className="close-dialog"
role="alertdialog"
tabIndex={-1}
aria-modal="true"
aria-labelledby="close-dialog-title"
aria-describedby="close-dialog-description"
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
}
}}
>
<div className="close-dialog-heading">
<span>关闭窗口</span>
<h2 id="close-dialog-title">请选择后续操作</h2>
</div>
<p id="close-dialog-description">
你可以退出程序,或让 EasyCLIProxyAPI 继续在系统托盘中运行。
</p>
{windowsClosePrompt.error ? (
<div className="close-dialog-error" role="alert">
{windowsClosePrompt.error}
</div>
) : null}
<div className="close-dialog-actions">
<button
type="button"
className="close-choice-button primary-button"
disabled={windowsClosePrompt.resolvingAction !== null}
onClick={() => void resolveWindowsCloseRequest('minimize-to-tray')}
>
<span>
{windowsClosePrompt.resolvingAction === 'minimize-to-tray'
? '正在最小化...'
: '最小化到托盘'}
</span>
</button>
<button
type="button"
className="close-choice-button danger-button"
disabled={windowsClosePrompt.resolvingAction !== null}
onClick={() => void resolveWindowsCloseRequest('exit')}
>
<span>
{windowsClosePrompt.resolvingAction === 'exit' ? '正在退出...' : '退出程序'}
</span>
</button>
</div>
</section>
</div>
) : null}
</>
);
}
+96
View File
@@ -1364,6 +1364,102 @@ input:focus-visible {
height: 40px;
}
.close-dialog-backdrop {
position: fixed;
inset: 0;
z-index: 110;
display: grid;
place-items: center;
padding: 24px;
background: rgba(45, 42, 38, 0.5);
backdrop-filter: blur(4px);
}
.close-dialog {
display: grid;
gap: 18px;
width: min(430px, 100%);
padding: 24px;
border: 1px solid #d5d2cb;
border-radius: 10px;
outline: none;
background: #fffdf8;
box-shadow: 0 22px 60px rgba(45, 42, 38, 0.28);
}
.close-dialog-heading {
display: grid;
gap: 5px;
}
.close-dialog-heading span {
color: #7a746c;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.04em;
}
.close-dialog-heading h2 {
margin: 0;
color: #2d2a26;
font-size: 20px;
line-height: 1.25;
}
.close-dialog > p {
margin: 0;
color: #5f5a53;
font-size: 13px;
line-height: 1.6;
}
.close-dialog-error {
padding: 9px 11px;
border: 1px solid #dfb7b0;
border-radius: 6px;
background: #fff1ee;
color: #9f2d20;
font-size: 12px;
line-height: 1.45;
overflow-wrap: anywhere;
}
.close-dialog-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.close-choice-button {
display: grid;
min-height: 58px;
gap: 3px;
padding: 8px 12px;
}
.close-choice-button span {
font-size: 13px;
}
.close-choice-button:focus-visible {
outline: 3px solid rgba(66, 99, 127, 0.36);
outline-offset: 2px;
}
@media (max-width: 520px) {
.close-dialog-backdrop {
padding: 16px;
}
.close-dialog {
padding: 20px;
}
.close-dialog-actions {
grid-template-columns: 1fr;
}
}
.config-delete-dialog {
grid-template-rows: 40px 52px 40px;
height: 202px;