From 7ad06cc54aa876443b6753dfbb9b02e40fc5c9bc Mon Sep 17 00:00:00 2001 From: impressionyang Date: Thu, 21 May 2026 18:07:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E6=89=98=E7=9B=98?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E3=80=81=E5=AE=8C=E5=85=A8=E9=80=80=E5=87=BA?= =?UTF-8?q?=E5=92=8C=E4=B8=BB=E9=A2=98=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 托盘功能: - 添加主题子菜单(浅色/深色/跟随系统) - 添加完全退出菜单项 - 托盘图标使用应用默认图标 完全退出功能: - 新增 AppState.allow_exit 状态控制 - 点击'完全退出'时允许应用真正退出 - 关闭窗口隐藏到托盘的默认行为 主题切换功能: - 后端:添加 AppTheme 枚举和 set_theme/get_theme 命令 - 前端:实现主题切换逻辑,支持浅色/深色/跟随系统 - 前端:添加主题选择器 UI 组件和样式 - 通过 CSS 变量实现深色/浅色主题切换 - 支持 Tauri 事件监听实现后端主题同步 修改文件: - src/app/state.rs: 添加 AppTheme 枚举和状态管理 - src/app/mod.rs: 完善托盘菜单和退出逻辑 - src/app/commands.rs: 添加主题相关 Tauri 命令 - web/src/App.tsx: 实现主题切换逻辑 - web/src/App.css: 添加主题 CSS 变量和选择器样式 - web/src/pages/SettingsPage.tsx: 添加主题选择器 UI --- src/app/commands.rs | 15 ++++- src/app/mod.rs | 112 ++++++++++++++++++++++++--------- src/app/state.rs | 53 ++++++++++++++++ web/src/App.css | 59 +++++++++++++++++ web/src/App.tsx | 60 +++++++++++++++++- web/src/pages/SettingsPage.tsx | 36 ++++++++--- 6 files changed, 291 insertions(+), 44 deletions(-) diff --git a/src/app/commands.rs b/src/app/commands.rs index e7b46d8..7095ce5 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use tauri::State; use tracing::{error, info}; -use super::state::AppState; +use super::state::{AppState, AppTheme}; /// 录音响应 #[derive(Debug, Serialize, Deserialize)] @@ -147,3 +147,16 @@ pub fn get_history( pub fn clear_history(state: State<'_, AppState>) { state.clear_history(); } + +/// 获取当前主题 +#[tauri::command] +pub fn get_theme(state: State<'_, AppState>) -> String { + state.get_theme().as_str().to_string() +} + +/// 设置主题 +#[tauri::command] +pub fn set_theme(theme: String, state: State<'_, AppState>) { + let app_theme = AppTheme::from_str(&theme); + state.set_theme(app_theme); +} diff --git a/src/app/mod.rs b/src/app/mod.rs index c1622fb..f1e77f3 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -15,6 +15,7 @@ pub mod state; /// 应用状态 pub use state::AppState; +pub use state::AppTheme; /// 运行 Tauri 应用 pub fn run() -> Result<()> { @@ -42,6 +43,8 @@ pub fn run() -> Result<()> { commands::save_config, commands::get_history, commands::clear_history, + commands::get_theme, + commands::set_theme, ]) .setup(|app| { info!("[设置] 初始化应用插件..."); @@ -145,16 +148,16 @@ pub fn run() -> Result<()> { // 处理窗口事件 match event { tauri::WindowEvent::CloseRequested { api, .. } => { - eprintln!(" [窗口] 关闭请求 - 隐藏窗口到托盘"); + info!(" [窗口] 关闭请求 - 隐藏窗口到托盘"); // 隐藏窗口而不是关闭 window.hide().unwrap(); api.prevent_close(); } tauri::WindowEvent::Focused(focused) => { if *focused { - eprintln!(" [窗口] 获得焦点"); + info!(" [窗口] 获得焦点"); } else { - eprintln!(" [窗口] 失去焦点"); + info!(" [窗口] 失去焦点"); } } _ => {} @@ -219,15 +222,24 @@ pub fn run() -> Result<()> { eprintln!(" [运行] 进入事件循环..."); info!("进入应用事件循环"); - app.run(|_app_handle, event| { + app.run(|app_handle, event| { // 处理全局事件 match event { + tauri::RunEvent::Exit => { + info!(" [事件] 应用退出"); + } tauri::RunEvent::ExitRequested { api, .. } => { - eprintln!(" [事件] 退出请求 - 阻止退出"); - api.prevent_exit(); + // 检查是否允许完全退出 + let state = app_handle.state::(); + if state.is_exit_allowed() { + info!(" [事件] 退出请求 - 允许完全退出"); + } else { + info!(" [事件] 退出请求 - 阻止退出(隐藏窗口到托盘)"); + api.prevent_exit(); + } } tauri::RunEvent::Ready => { - eprintln!(" [事件] 应用已就绪"); + info!(" [事件] 应用已就绪"); } _ => {} } @@ -238,35 +250,43 @@ pub fn run() -> Result<()> { /// 设置系统托盘 fn setup_tray(app: &tauri::App) -> Result<()> { - eprintln!(" [托盘] 创建菜单项..."); + info!(" [托盘] 创建菜单项..."); let show = MenuItem::with_id(app, "show", "显示窗口", true, None::<&str>)?; - eprintln!(" - '显示窗口' 菜单项已创建"); + info!(" - '显示窗口' 菜单项已创建"); let record = MenuItem::with_id(app, "record", "开始录音", true, None::<&str>)?; - eprintln!(" - '开始录音' 菜单项已创建"); + info!(" - '开始录音' 菜单项已创建"); let settings = MenuItem::with_id(app, "settings", "设置", true, None::<&str>)?; - eprintln!(" - '设置' 菜单项已创建"); + info!(" - '设置' 菜单项已创建"); - let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?; - eprintln!(" - '退出' 菜单项已创建"); + // 主题子菜单 + let theme_light = MenuItem::with_id(app, "theme_light", "浅色主题", true, None::<&str>)?; + let theme_dark = MenuItem::with_id(app, "theme_dark", "深色主题", true, None::<&str>)?; + let theme_system = MenuItem::with_id(app, "theme_system", "跟随系统", true, None::<&str>)?; + let theme_menu = Menu::with_items(app, &[&theme_light, &theme_dark, &theme_system])?; + info!(" - '主题' 子菜单已创建(浅色/深色/跟随系统)"); - eprintln!(" [托盘] 组合菜单..."); - let menu = Menu::with_items(app, &[&show, &record, &settings, &quit])?; - eprintln!(" ✓ 菜单创建成功 (4 项)"); + // 完全退出选项 + let quit_now = MenuItem::with_id(app, "quit_now", "完全退出", true, None::<&str>)?; + info!(" - '完全退出' 菜单项已创建"); - eprintln!(" [托盘] 加载图标..."); + info!(" [托盘] 组合菜单..."); + let menu = Menu::with_items(app, &[&show, &record, &settings, &theme_menu, &quit_now])?; + info!(" ✓ 菜单创建成功 (5 项 + 主题子菜单)"); + + info!(" [托盘] 加载图标..."); let icon = app.default_window_icon().cloned(); match icon { - Some(_) => eprintln!(" ✓ 窗口图标加载成功"), + Some(_) => info!(" ✓ 窗口图标加载成功"), None => { - eprintln!(" ⚠ 窗口图标未找到,使用默认图标"); + info!(" ⚠ 窗口图标未找到,使用默认图标"); warn!("窗口图标未找到"); } } - eprintln!(" [托盘] 创建托盘图标..."); + info!(" [托盘] 创建托盘图标..."); let _tray = TrayIconBuilder::new() .icon(app.default_window_icon().cloned().unwrap_or_else(|| { warn!("使用空图标"); @@ -279,30 +299,60 @@ fn setup_tray(app: &tauri::App) -> Result<()> { info!("托盘菜单事件:{:?}", event.id); match event.id.as_ref() { "show" => { - eprintln!(" [托盘] '显示窗口' 被点击"); + info!(" [托盘] '显示窗口' 被点击"); if let Some(window) = app.get_webview_window("main") { let _ = window.show(); let _ = window.set_focus(); - eprintln!(" ✓ 窗口已显示并聚焦"); + info!(" ✓ 窗口已显示并聚焦"); } } "record" => { - eprintln!(" [托盘] '开始录音' 被点击"); - // 触发录音 + info!(" [托盘] '开始录音' 被点击"); info!("从托盘启动录音"); } "settings" => { - eprintln!(" [托盘] '设置' 被点击"); + info!(" [托盘] '设置' 被点击"); if let Some(window) = app.get_webview_window("main") { let _ = window.show(); let _ = window.set_focus(); - eprintln!(" ✓ 窗口已显示并聚焦"); - // 导航到设置页面 + info!(" ✓ 窗口已显示并聚焦"); } } - "quit" => { - eprintln!(" [托盘] '退出' 被点击"); - info!("从托盘退出应用"); + "theme_light" => { + info!(" [托盘] '浅色主题' 被点击"); + let state = app.state::(); + state.set_theme(AppTheme::Light); + // 通知前端切换主题 + if let Some(window) = app.get_webview_window("main") { + let _ = window.emit("theme-change", "light"); + } + } + "theme_dark" => { + info!(" [托盘] '深色主题' 被点击"); + let state = app.state::(); + state.set_theme(AppTheme::Dark); + if let Some(window) = app.get_webview_window("main") { + let _ = window.emit("theme-change", "dark"); + } + } + "theme_system" => { + info!(" [托盘] '跟随系统' 被点击"); + let state = app.state::(); + state.set_theme(AppTheme::System); + if let Some(window) = app.get_webview_window("main") { + let _ = window.emit("theme-change", "system"); + } + } + "quit_now" => { + info!(" [托盘] '完全退出' 被点击"); + // 允许完全退出 + let state = app.state::(); + state.allow_exit(); + // 关闭窗口 + if let Some(window) = app.get_webview_window("main") { + let _ = window.close(); + } + // 退出应用 app.exit(0); } _ => {} @@ -310,7 +360,7 @@ fn setup_tray(app: &tauri::App) -> Result<()> { }) .build(app)?; - eprintln!(" ✓ 托盘图标创建成功"); + info!(" ✓ 托盘图标创建成功"); info!("系统托盘设置完成"); Ok(()) diff --git a/src/app/state.rs b/src/app/state.rs index d90e225..a218012 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -4,6 +4,33 @@ use crate::config::HistoryEntry; use parking_lot::RwLock; use std::collections::VecDeque; +/// 应用主题 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AppTheme { + #[default] + Light, + Dark, + System, +} + +impl AppTheme { + pub fn as_str(&self) -> &'static str { + match self { + AppTheme::Light => "light", + AppTheme::Dark => "dark", + AppTheme::System => "system", + } + } + + pub fn from_str(s: &str) -> Self { + match s { + "light" => AppTheme::Light, + "dark" => AppTheme::Dark, + _ => AppTheme::System, + } + } +} + /// 应用最大历史记录数 const MAX_HISTORY: usize = 100; @@ -18,6 +45,10 @@ pub struct AppState { history: RwLock>, /// 当前使用的模型名称 current_model: RwLock, + /// 当前主题 + current_theme: RwLock, + /// 是否允许完全退出 + allow_exit: RwLock, } impl AppState { @@ -85,6 +116,26 @@ impl AppState { pub fn get_current_model(&self) -> String { self.current_model.read().clone() } + + /// 设置当前主题 + pub fn set_theme(&self, theme: AppTheme) { + *self.current_theme.write() = theme; + } + + /// 获取当前主题 + pub fn get_theme(&self) -> AppTheme { + *self.current_theme.read() + } + + /// 允许完全退出 + pub fn allow_exit(&self) { + *self.allow_exit.write() = true; + } + + /// 检查是否允许完全退出 + pub fn is_exit_allowed(&self) -> bool { + *self.allow_exit.read() + } } impl Clone for AppState { @@ -94,6 +145,8 @@ impl Clone for AppState { current_recording_path: RwLock::new(self.current_recording_path.read().clone()), history: RwLock::new(self.history.read().clone()), current_model: RwLock::new(self.current_model.read().clone()), + current_theme: RwLock::new(*self.current_theme.read()), + allow_exit: RwLock::new(*self.allow_exit.read()), } } } diff --git a/web/src/App.css b/web/src/App.css index a0cbb70..cff30b6 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -4,6 +4,33 @@ box-sizing: border-box; } +/* 深色主题(默认) */ +.light { + --bg-primary: #f5f5f5; + --bg-secondary: #ffffff; + --bg-tertiary: #e0e0e0; + --text-primary: #1a1a2e; + --text-secondary: #666666; + --accent: #e94560; + --accent-hover: #ff6b6b; + --success: #22c55e; + --warning: #f59e0b; + --border: #e0e0e0; +} + +.dark { + --bg-primary: #1a1a2e; + --bg-secondary: #16213e; + --bg-tertiary: #0f3460; + --text-primary: #ffffff; + --text-secondary: #a0a0a0; + --accent: #e94560; + --accent-hover: #ff6b6b; + --success: #4ade80; + --warning: #fbbf24; + --border: #2d3748; +} + :root { --bg-primary: #1a1a2e; --bg-secondary: #16213e; @@ -240,3 +267,35 @@ body { ::-webkit-scrollbar-thumb:hover { background: #1a4a7a; } + +/* 主题选择器 */ +.theme-selector { + display: flex; + gap: 8px; +} + +.theme-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px 16px; + background: var(--bg-tertiary); + border: 2px solid var(--border); + border-radius: 8px; + color: var(--text-primary); + font-size: 14px; + cursor: pointer; + transition: all 0.2s; +} + +.theme-btn:hover { + border-color: var(--accent); + background: var(--bg-secondary); +} + +.theme-btn.active { + border-color: var(--accent); + background: var(--accent); + color: var(--text-primary); +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 1c95f0e..eac3ada 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,13 +1,69 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import './App.css' import RecordPage from './pages/RecordPage' import FileConvertPage from './pages/FileConvertPage' import SettingsPage from './pages/SettingsPage' type Page = 'record' | 'file' | 'settings' +type Theme = 'light' | 'dark' | 'system' + +declare const window: Window & { + __TAURI__: { + invoke: (cmd: string, args?: Record) => Promise + listen: (event: string, handler: (e: { payload: unknown }) => void) => Promise<() => void> + } +} function App() { const [currentPage, setCurrentPage] = useState('record') + const [theme, setTheme] = useState('system') + + // 获取当前主题 + useEffect(() => { + const loadTheme = async () => { + try { + const currentTheme = await window.__TAURI__.invoke('get_theme') + setTheme(currentTheme as Theme) + applyTheme(currentTheme as Theme) + } catch (e) { + console.error('Failed to load theme:', e) + } + } + loadTheme() + + // 监听主题变化事件 + const unlisten = window.__TAURI__.listen('theme-change', (event) => { + const newTheme = event.payload as string + setTheme(newTheme as Theme) + applyTheme(newTheme as Theme) + }) + + return () => { + unlisten.then(fn => fn()) + } + }, []) + + const applyTheme = (newTheme: Theme) => { + const root = document.documentElement + root.classList.remove('light', 'dark') + + if (newTheme === 'system') { + const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches + root.classList.add(systemDark ? 'dark' : 'light') + } else { + root.classList.add(newTheme) + } + } + + const handleThemeChange = async (newTheme: Theme) => { + try { + await window.__TAURI__.invoke('set_theme', { theme: newTheme }) + setTheme(newTheme) + applyTheme(newTheme) + } catch (e) { + console.error('Failed to set theme:', e) + } + } const renderPage = () => { switch (currentPage) { @@ -16,7 +72,7 @@ function App() { case 'file': return case 'settings': - return + return default: return } diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 2e9d06d..8adb04e 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -15,7 +15,12 @@ interface Settings { hotkeyToggle: string } -export default function SettingsPage() { +interface SettingsPageProps { + theme: 'light' | 'dark' | 'system' + onThemeChange: (theme: 'light' | 'dark' | 'system') => void +} + +export default function SettingsPage({ theme, onThemeChange }: SettingsPageProps) { const [settings, setSettings] = useState({ model: 'sensevoice-small', language: 'zh', @@ -188,15 +193,26 @@ export default function SettingsPage() {
主题
- +
+ + + +