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

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "cpa-gui",
"private": true,
"version": "0.2.3",
"version": "0.2.4",
"type": "module",
"packageManager": "bun@1.3.14",
"scripts": {
+1 -1
View File
@@ -582,7 +582,7 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
[[package]]
name = "cpa-gui"
version = "0.2.3"
version = "0.2.4"
dependencies = [
"base64 0.22.1",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cpa-gui"
version = "0.2.3"
version = "0.2.4"
description = "CPA desktop management shell"
authors = ["lzt404"]
edition = "2021"
+470 -19
View File
@@ -10,6 +10,8 @@ use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy, NSEvent};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[cfg(target_os = "windows")]
use std::sync::atomic::{AtomicBool, Ordering};
#[cfg(target_os = "macos")]
use std::sync::Arc;
use std::{
@@ -24,10 +26,14 @@ use std::{
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use tar::Archive;
#[cfg(target_os = "windows")]
use tauri::menu::PredefinedMenuItem;
#[cfg(target_os = "macos")]
use tauri::tray::{MouseButtonState, TrayIcon};
#[cfg(any(target_os = "macos", target_os = "windows"))]
use tauri::{
menu::{Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent},
tray::{MouseButton, TrayIconBuilder, TrayIconEvent},
};
use tauri::{Emitter, Manager};
use tauri_plugin_opener::OpenerExt;
@@ -42,6 +48,8 @@ const APP_RELEASE_PAGE_URL: &str =
"https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest";
const CORE_INSTALL_PROGRESS_EVENT: &str = "core-install-progress";
const CORE_STATUS_EVENT: &str = "core-status-changed";
#[cfg(target_os = "windows")]
const WINDOWS_CLOSE_REQUEST_EVENT: &str = "windows-close-requested";
const CORE_METADATA_FILE: &str = "cpa-gui-meta.json";
const CORE_CONFIG_FILE: &str = "config.yaml";
const CORE_EXAMPLE_CONFIG_FILE: &str = "config.example.yaml";
@@ -225,6 +233,13 @@ struct GuiConfigFile {
routing_strategy: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
enum WindowsCloseAction {
Exit,
MinimizeToTray,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct GuiApiKeyEntry {
key: String,
@@ -897,6 +912,8 @@ fn get_core_status(
}
fn emit_core_status(app: &tauri::AppHandle, status: &CoreStatus) {
#[cfg(target_os = "windows")]
update_windows_tray_status(app, status);
let _ = app.emit(CORE_STATUS_EVENT, status);
}
@@ -908,6 +925,26 @@ fn get_gui_settings(
Ok(GuiSettings::from(&config))
}
#[tauri::command]
fn resolve_windows_close_request(
app: tauri::AppHandle,
action: WindowsCloseAction,
) -> Result<(), String> {
match action {
WindowsCloseAction::Exit => app.exit(0),
WindowsCloseAction::MinimizeToTray => {
let window = app
.get_webview_window("main")
.ok_or_else(|| "主窗口不存在".to_string())?;
window
.hide()
.map_err(|error| format!("隐藏主窗口失败: {error}"))?;
}
}
Ok(())
}
fn inspect_agent_config_statuses(
app: &tauri::AppHandle,
port: u16,
@@ -4860,48 +4897,69 @@ fn start_core_process(
process_state: tauri::State<'_, CoreProcessState>,
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<CoreStatus, String> {
let config = gui_config_state.snapshot()?;
start_core_process_inner(process_state.inner(), &config)?;
if let Err(error) = gui_config_state.set_run_on_startup(true) {
let _ = stop_core_process_inner(process_state.inner());
return Err(error);
}
let status = current_core_status(Some(process_state.inner()), Some(config.port))?;
let status = start_core_process_with_state(process_state.inner(), gui_config_state.inner())?;
emit_core_status(&app, &status);
Ok(status)
}
fn start_core_process_with_state(
process_state: &CoreProcessState,
gui_config_state: &GuiConfigState,
) -> Result<CoreStatus, String> {
let config = gui_config_state.snapshot()?;
start_core_process_inner(process_state, &config)?;
if let Err(error) = gui_config_state.set_run_on_startup(true) {
let _ = stop_core_process_inner(process_state);
return Err(error);
}
current_core_status(Some(process_state), Some(config.port))
}
#[tauri::command]
fn stop_core_process(
app: tauri::AppHandle,
process_state: tauri::State<'_, CoreProcessState>,
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<CoreStatus, String> {
stop_core_process_inner(process_state.inner())?;
let config = gui_config_state.set_run_on_startup(false)?;
let status = current_core_status(Some(process_state.inner()), Some(config.port))?;
let status = stop_core_process_with_state(process_state.inner(), gui_config_state.inner())?;
emit_core_status(&app, &status);
Ok(status)
}
fn stop_core_process_with_state(
process_state: &CoreProcessState,
gui_config_state: &GuiConfigState,
) -> Result<CoreStatus, String> {
stop_core_process_inner(process_state)?;
let config = gui_config_state.set_run_on_startup(false)?;
current_core_status(Some(process_state), Some(config.port))
}
#[tauri::command]
fn restart_core_process(
app: tauri::AppHandle,
process_state: tauri::State<'_, CoreProcessState>,
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<CoreStatus, String> {
let config = gui_config_state.snapshot()?;
let _ = stop_core_process_inner(process_state.inner());
start_core_process_inner(process_state.inner(), &config)?;
if let Err(error) = gui_config_state.set_run_on_startup(true) {
let _ = stop_core_process_inner(process_state.inner());
return Err(error);
}
let status = current_core_status(Some(process_state.inner()), Some(config.port))?;
let status = restart_core_process_with_state(process_state.inner(), gui_config_state.inner())?;
emit_core_status(&app, &status);
Ok(status)
}
fn restart_core_process_with_state(
process_state: &CoreProcessState,
gui_config_state: &GuiConfigState,
) -> Result<CoreStatus, String> {
let config = gui_config_state.snapshot()?;
let _ = stop_core_process_inner(process_state);
start_core_process_inner(process_state, &config)?;
if let Err(error) = gui_config_state.set_run_on_startup(true) {
let _ = stop_core_process_inner(process_state);
return Err(error);
}
current_core_status(Some(process_state), Some(config.port))
}
async fn install_core_version_inner(
window: &tauri::Window,
state: &CoreDownloadState,
@@ -8764,6 +8822,11 @@ fn show_main_window_on_main_thread(app_handle: &tauri::AppHandle) {
set_macos_dock_visible(false);
return;
}
if window.is_minimized().unwrap_or(false) {
if let Err(error) = window.unminimize() {
eprintln!("恢复主窗口失败: {error}");
}
}
if let Err(error) = window.set_focus() {
eprintln!("聚焦主窗口失败: {error}");
}
@@ -8887,6 +8950,341 @@ fn setup_macos_tray(app: &mut tauri::App<tauri::Wry>) -> tauri::Result<()> {
Ok(())
}
#[cfg(target_os = "windows")]
const WINDOWS_TRAY_ID: &str = "windows-tray";
#[cfg(target_os = "windows")]
const WINDOWS_TRAY_OPEN_MENU_ID: &str = "windows-tray-open-main-window";
#[cfg(target_os = "windows")]
const WINDOWS_TRAY_STATUS_MENU_ID: &str = "windows-tray-core-status";
#[cfg(target_os = "windows")]
const WINDOWS_TRAY_TOGGLE_CORE_MENU_ID: &str = "windows-tray-toggle-core";
#[cfg(target_os = "windows")]
const WINDOWS_TRAY_RESTART_CORE_MENU_ID: &str = "windows-tray-restart-core";
#[cfg(target_os = "windows")]
const WINDOWS_TRAY_QUIT_MENU_ID: &str = "windows-tray-quit";
#[cfg(target_os = "windows")]
#[derive(Clone, Copy)]
enum WindowsTrayCoreAction {
Toggle,
Restart,
}
#[cfg(target_os = "windows")]
struct WindowsTrayPresentation {
status_text: &'static str,
toggle_text: &'static str,
toggle_enabled: bool,
restart_enabled: bool,
tooltip: &'static str,
}
#[cfg(target_os = "windows")]
fn windows_tray_presentation(status: &CoreStatus, busy: bool) -> WindowsTrayPresentation {
let status_text = if busy {
"内核状态:处理中"
} else if !status.installed {
"内核状态:未安装"
} else if status.running {
"内核状态:运行中"
} else {
"内核状态:已停止"
};
let toggle_text = if busy {
"处理中..."
} else if status.running {
"停止内核"
} else {
"启动内核"
};
let tooltip = if busy {
"EasyCLIProxyAPI · 内核处理中"
} else if !status.installed {
"EasyCLIProxyAPI · 内核未安装"
} else if status.running {
"EasyCLIProxyAPI · 内核运行中"
} else {
"EasyCLIProxyAPI · 内核已停止"
};
WindowsTrayPresentation {
status_text,
toggle_text,
toggle_enabled: status.installed && !busy,
restart_enabled: status.installed && status.running && !busy,
tooltip,
}
}
#[cfg(target_os = "windows")]
struct WindowsTrayState {
status_item: MenuItem<tauri::Wry>,
toggle_core_item: MenuItem<tauri::Wry>,
restart_core_item: MenuItem<tauri::Wry>,
busy: AtomicBool,
}
#[cfg(target_os = "windows")]
impl WindowsTrayState {
fn begin_action(&self) -> bool {
if self
.busy
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
return false;
}
if let Err(error) = self.status_item.set_text("内核状态:处理中") {
eprintln!("更新 Windows 托盘内核状态失败: {error}");
}
if let Err(error) = self.toggle_core_item.set_text("处理中...") {
eprintln!("更新 Windows 托盘操作文本失败: {error}");
}
if let Err(error) = self.toggle_core_item.set_enabled(false) {
eprintln!("更新 Windows 托盘操作状态失败: {error}");
}
if let Err(error) = self.restart_core_item.set_enabled(false) {
eprintln!("更新 Windows 托盘重启状态失败: {error}");
}
true
}
fn finish_action(&self) {
self.busy.store(false, Ordering::Release);
}
fn update(&self, status: &CoreStatus) -> &'static str {
let presentation = windows_tray_presentation(status, self.busy.load(Ordering::Acquire));
if let Err(error) = self.status_item.set_text(presentation.status_text) {
eprintln!("更新 Windows 托盘内核状态失败: {error}");
}
if let Err(error) = self.toggle_core_item.set_text(presentation.toggle_text) {
eprintln!("更新 Windows 托盘操作文本失败: {error}");
}
if let Err(error) = self
.toggle_core_item
.set_enabled(presentation.toggle_enabled)
{
eprintln!("更新 Windows 托盘操作状态失败: {error}");
}
if let Err(error) = self
.restart_core_item
.set_enabled(presentation.restart_enabled)
{
eprintln!("更新 Windows 托盘重启状态失败: {error}");
}
presentation.tooltip
}
fn show_error(&self, error: &str) {
let mut summary = error.chars().take(48).collect::<String>();
if error.chars().count() > 48 {
summary.push('…');
}
if let Err(update_error) = self.status_item.set_text(format!("操作失败:{summary}")) {
eprintln!("更新 Windows 托盘错误状态失败: {update_error}");
}
}
}
#[cfg(target_os = "windows")]
fn show_windows_main_window(app_handle: &tauri::AppHandle) {
let Some(window) = app_handle.get_webview_window("main") else {
return;
};
if let Err(error) = window.show() {
eprintln!("显示主窗口失败: {error}");
return;
}
if window.is_minimized().unwrap_or(false) {
if let Err(error) = window.unminimize() {
eprintln!("恢复主窗口失败: {error}");
}
}
if let Err(error) = window.set_focus() {
eprintln!("聚焦主窗口失败: {error}");
}
}
#[cfg(target_os = "windows")]
fn update_windows_tray_status(app_handle: &tauri::AppHandle, status: &CoreStatus) {
let tooltip = app_handle
.try_state::<WindowsTrayState>()
.map(|tray_state| tray_state.update(status))
.unwrap_or_else(|| windows_tray_presentation(status, false).tooltip);
if let Some(tray) = app_handle.tray_by_id(WINDOWS_TRAY_ID) {
if let Err(error) = tray.set_tooltip(Some(tooltip)) {
eprintln!("更新 Windows 托盘提示失败: {error}");
}
}
}
#[cfg(target_os = "windows")]
fn show_windows_tray_action_error(app_handle: &tauri::AppHandle, error: &str) {
eprintln!("Windows 托盘内核操作失败: {error}");
if let Some(tray_state) = app_handle.try_state::<WindowsTrayState>() {
tray_state.show_error(error);
}
if let Some(tray) = app_handle.tray_by_id(WINDOWS_TRAY_ID) {
if let Err(update_error) = tray.set_tooltip(Some("EasyCLIProxyAPI · 内核操作失败")) {
eprintln!("更新 Windows 托盘错误提示失败: {update_error}");
}
}
}
#[cfg(target_os = "windows")]
fn run_windows_tray_core_action(app_handle: &tauri::AppHandle, action: WindowsTrayCoreAction) {
let Some(tray_state) = app_handle.try_state::<WindowsTrayState>() else {
return;
};
if !tray_state.begin_action() {
return;
}
if let Some(tray) = app_handle.tray_by_id(WINDOWS_TRAY_ID) {
if let Err(error) = tray.set_tooltip(Some("EasyCLIProxyAPI · 内核处理中")) {
eprintln!("更新 Windows 托盘处理中提示失败: {error}");
}
}
let app_handle = app_handle.clone();
tauri::async_runtime::spawn_blocking(move || {
let process_state = app_handle.state::<CoreProcessState>();
let gui_config_state = app_handle.state::<GuiConfigState>();
let result = (|| -> Result<CoreStatus, String> {
match action {
WindowsTrayCoreAction::Toggle => {
let config = gui_config_state.snapshot()?;
let status =
current_core_status(Some(process_state.inner()), Some(config.port))?;
if status.running {
stop_core_process_with_state(
process_state.inner(),
gui_config_state.inner(),
)
} else {
start_core_process_with_state(
process_state.inner(),
gui_config_state.inner(),
)
}
}
WindowsTrayCoreAction::Restart => {
restart_core_process_with_state(process_state.inner(), gui_config_state.inner())
}
}
})();
if let Some(tray_state) = app_handle.try_state::<WindowsTrayState>() {
tray_state.finish_action();
}
match result {
Ok(status) => emit_core_status(&app_handle, &status),
Err(error) => {
if let Ok(config) = gui_config_state.snapshot() {
if let Ok(status) =
current_core_status(Some(process_state.inner()), Some(config.port))
{
emit_core_status(&app_handle, &status);
}
}
show_windows_tray_action_error(&app_handle, &error);
}
}
});
}
#[cfg(target_os = "windows")]
fn setup_windows_tray(app: &mut tauri::App<tauri::Wry>) -> tauri::Result<()> {
let open_main_window = MenuItem::with_id(
app,
WINDOWS_TRAY_OPEN_MENU_ID,
"打开主界面",
true,
None::<&str>,
)?;
let status_item = MenuItem::with_id(
app,
WINDOWS_TRAY_STATUS_MENU_ID,
"内核状态:正在检查",
false,
None::<&str>,
)?;
let toggle_core_item = MenuItem::with_id(
app,
WINDOWS_TRAY_TOGGLE_CORE_MENU_ID,
"启动内核",
false,
None::<&str>,
)?;
let restart_core_item = MenuItem::with_id(
app,
WINDOWS_TRAY_RESTART_CORE_MENU_ID,
"重启内核",
false,
None::<&str>,
)?;
let quit = MenuItem::with_id(app, WINDOWS_TRAY_QUIT_MENU_ID, "退出", true, None::<&str>)?;
let separator_one = PredefinedMenuItem::separator(app)?;
let separator_two = PredefinedMenuItem::separator(app)?;
let menu = Menu::with_items(
app,
&[
&open_main_window,
&separator_one,
&status_item,
&toggle_core_item,
&restart_core_item,
&separator_two,
&quit,
],
)?;
TrayIconBuilder::with_id(WINDOWS_TRAY_ID)
.icon(
app.default_window_icon()
.cloned()
.expect("application icon is required for the tray"),
)
.tooltip("EasyCLIProxyAPI")
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(move |app_handle, event| match event.id().as_ref() {
WINDOWS_TRAY_OPEN_MENU_ID => show_windows_main_window(app_handle),
WINDOWS_TRAY_TOGGLE_CORE_MENU_ID => {
run_windows_tray_core_action(app_handle, WindowsTrayCoreAction::Toggle)
}
WINDOWS_TRAY_RESTART_CORE_MENU_ID => {
run_windows_tray_core_action(app_handle, WindowsTrayCoreAction::Restart)
}
WINDOWS_TRAY_QUIT_MENU_ID => app_handle.exit(0),
_ => {}
})
.on_tray_icon_event(move |tray, event| {
if matches!(
event,
TrayIconEvent::DoubleClick {
button: MouseButton::Left,
..
}
) {
show_windows_main_window(tray.app_handle());
}
})
.build(app)?;
let _ = app.manage(WindowsTrayState {
status_item,
toggle_core_item,
restart_core_item,
busy: AtomicBool::new(false),
});
Ok(())
}
fn main() {
let gui_config = match load_or_create_gui_config() {
Ok(config) => config,
@@ -8928,10 +9326,24 @@ fn main() {
}
});
#[cfg(target_os = "windows")]
let app = app.on_window_event(|window, event| {
if window.label() == "main" {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
if let Err(error) = window.emit(WINDOWS_CLOSE_REQUEST_EVENT, ()) {
eprintln!("显示 Windows 关闭确认失败: {error}");
}
}
}
});
let app = app
.setup(|app| {
#[cfg(target_os = "macos")]
setup_macos_tray(app)?;
#[cfg(target_os = "windows")]
setup_windows_tray(app)?;
let usage_app = app.handle().clone();
tauri::async_runtime::spawn_blocking(move || {
@@ -8982,6 +9394,7 @@ fn main() {
detect_core_platform,
get_core_status,
get_gui_settings,
resolve_windows_close_request,
get_agent_config_statuses,
refresh_agent_config_statuses,
get_agent_models,
@@ -9497,6 +9910,44 @@ mod tests {
assert_eq!(native.get_args().count(), 0);
}
#[cfg(target_os = "windows")]
#[test]
fn windows_tray_presentation_tracks_core_state_and_busy_actions() {
let mut status = CoreStatus {
installed: false,
running: false,
managed: false,
process_id: None,
current_version: None,
install_dir: String::new(),
binary_path: None,
message: String::new(),
};
let missing = windows_tray_presentation(&status, false);
assert_eq!(missing.status_text, "内核状态:未安装");
assert!(!missing.toggle_enabled);
assert!(!missing.restart_enabled);
status.installed = true;
let stopped = windows_tray_presentation(&status, false);
assert_eq!(stopped.toggle_text, "启动内核");
assert!(stopped.toggle_enabled);
assert!(!stopped.restart_enabled);
status.running = true;
let running = windows_tray_presentation(&status, false);
assert_eq!(running.status_text, "内核状态:运行中");
assert_eq!(running.toggle_text, "停止内核");
assert!(running.toggle_enabled);
assert!(running.restart_enabled);
let busy = windows_tray_presentation(&status, true);
assert_eq!(busy.status_text, "内核状态:处理中");
assert!(!busy.toggle_enabled);
assert!(!busy.restart_enabled);
}
#[cfg(target_os = "windows")]
#[test]
fn windows_chatgpt_discovery_parser_accepts_registered_app_and_executable() {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "EasyCLIProxyAPI",
"version": "0.2.3",
"version": "0.2.4",
"identifier": "com.cpa.gui",
"build": {
"beforeDevCommand": "bun run dev",
+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;