feat: 完善托盘功能、完全退出和主题切换
Some checks are pending
Build Windows GUI / build-windows (push) Waiting to run
Build Windows GUI / release (push) Blocked by required conditions

托盘功能:
- 添加主题子菜单(浅色/深色/跟随系统)
- 添加完全退出菜单项
- 托盘图标使用应用默认图标

完全退出功能:
- 新增 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
This commit is contained in:
impressionyang 2026-05-21 18:07:38 +08:00
parent ceb2df18c4
commit 7ad06cc54a
6 changed files with 291 additions and 44 deletions

View File

@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
use tauri::State; use tauri::State;
use tracing::{error, info}; use tracing::{error, info};
use super::state::AppState; use super::state::{AppState, AppTheme};
/// 录音响应 /// 录音响应
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@ -147,3 +147,16 @@ pub fn get_history(
pub fn clear_history(state: State<'_, AppState>) { pub fn clear_history(state: State<'_, AppState>) {
state.clear_history(); 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);
}

View File

@ -15,6 +15,7 @@ pub mod state;
/// 应用状态 /// 应用状态
pub use state::AppState; pub use state::AppState;
pub use state::AppTheme;
/// 运行 Tauri 应用 /// 运行 Tauri 应用
pub fn run() -> Result<()> { pub fn run() -> Result<()> {
@ -42,6 +43,8 @@ pub fn run() -> Result<()> {
commands::save_config, commands::save_config,
commands::get_history, commands::get_history,
commands::clear_history, commands::clear_history,
commands::get_theme,
commands::set_theme,
]) ])
.setup(|app| { .setup(|app| {
info!("[设置] 初始化应用插件..."); info!("[设置] 初始化应用插件...");
@ -145,16 +148,16 @@ pub fn run() -> Result<()> {
// 处理窗口事件 // 处理窗口事件
match event { match event {
tauri::WindowEvent::CloseRequested { api, .. } => { tauri::WindowEvent::CloseRequested { api, .. } => {
eprintln!(" [窗口] 关闭请求 - 隐藏窗口到托盘"); info!(" [窗口] 关闭请求 - 隐藏窗口到托盘");
// 隐藏窗口而不是关闭 // 隐藏窗口而不是关闭
window.hide().unwrap(); window.hide().unwrap();
api.prevent_close(); api.prevent_close();
} }
tauri::WindowEvent::Focused(focused) => { tauri::WindowEvent::Focused(focused) => {
if *focused { if *focused {
eprintln!(" [窗口] 获得焦点"); info!(" [窗口] 获得焦点");
} else { } else {
eprintln!(" [窗口] 失去焦点"); info!(" [窗口] 失去焦点");
} }
} }
_ => {} _ => {}
@ -219,15 +222,24 @@ pub fn run() -> Result<()> {
eprintln!(" [运行] 进入事件循环..."); eprintln!(" [运行] 进入事件循环...");
info!("进入应用事件循环"); info!("进入应用事件循环");
app.run(|_app_handle, event| { app.run(|app_handle, event| {
// 处理全局事件 // 处理全局事件
match event { match event {
tauri::RunEvent::Exit => {
info!(" [事件] 应用退出");
}
tauri::RunEvent::ExitRequested { api, .. } => { tauri::RunEvent::ExitRequested { api, .. } => {
eprintln!(" [事件] 退出请求 - 阻止退出"); // 检查是否允许完全退出
api.prevent_exit(); let state = app_handle.state::<AppState>();
if state.is_exit_allowed() {
info!(" [事件] 退出请求 - 允许完全退出");
} else {
info!(" [事件] 退出请求 - 阻止退出(隐藏窗口到托盘)");
api.prevent_exit();
}
} }
tauri::RunEvent::Ready => { tauri::RunEvent::Ready => {
eprintln!(" [事件] 应用已就绪"); info!(" [事件] 应用已就绪");
} }
_ => {} _ => {}
} }
@ -238,35 +250,43 @@ pub fn run() -> Result<()> {
/// 设置系统托盘 /// 设置系统托盘
fn setup_tray(app: &tauri::App) -> Result<()> { fn setup_tray(app: &tauri::App) -> Result<()> {
eprintln!(" [托盘] 创建菜单项..."); info!(" [托盘] 创建菜单项...");
let show = MenuItem::with_id(app, "show", "显示窗口", true, None::<&str>)?; let show = MenuItem::with_id(app, "show", "显示窗口", true, None::<&str>)?;
eprintln!(" - '显示窗口' 菜单项已创建"); info!(" - '显示窗口' 菜单项已创建");
let record = MenuItem::with_id(app, "record", "开始录音", true, None::<&str>)?; let record = MenuItem::with_id(app, "record", "开始录音", true, None::<&str>)?;
eprintln!(" - '开始录音' 菜单项已创建"); info!(" - '开始录音' 菜单项已创建");
let settings = MenuItem::with_id(app, "settings", "设置", true, None::<&str>)?; 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])?; let quit_now = MenuItem::with_id(app, "quit_now", "完全退出", true, None::<&str>)?;
eprintln!(" ✓ 菜单创建成功 (4 项)"); 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(); let icon = app.default_window_icon().cloned();
match icon { match icon {
Some(_) => eprintln!(" ✓ 窗口图标加载成功"), Some(_) => info!(" ✓ 窗口图标加载成功"),
None => { None => {
eprintln!(" ⚠ 窗口图标未找到,使用默认图标"); info!(" ⚠ 窗口图标未找到,使用默认图标");
warn!("窗口图标未找到"); warn!("窗口图标未找到");
} }
} }
eprintln!(" [托盘] 创建托盘图标..."); info!(" [托盘] 创建托盘图标...");
let _tray = TrayIconBuilder::new() let _tray = TrayIconBuilder::new()
.icon(app.default_window_icon().cloned().unwrap_or_else(|| { .icon(app.default_window_icon().cloned().unwrap_or_else(|| {
warn!("使用空图标"); warn!("使用空图标");
@ -279,30 +299,60 @@ fn setup_tray(app: &tauri::App) -> Result<()> {
info!("托盘菜单事件:{:?}", event.id); info!("托盘菜单事件:{:?}", event.id);
match event.id.as_ref() { match event.id.as_ref() {
"show" => { "show" => {
eprintln!(" [托盘] '显示窗口' 被点击"); info!(" [托盘] '显示窗口' 被点击");
if let Some(window) = app.get_webview_window("main") { if let Some(window) = app.get_webview_window("main") {
let _ = window.show(); let _ = window.show();
let _ = window.set_focus(); let _ = window.set_focus();
eprintln!(" ✓ 窗口已显示并聚焦"); info!(" ✓ 窗口已显示并聚焦");
} }
} }
"record" => { "record" => {
eprintln!(" [托盘] '开始录音' 被点击"); info!(" [托盘] '开始录音' 被点击");
// 触发录音
info!("从托盘启动录音"); info!("从托盘启动录音");
} }
"settings" => { "settings" => {
eprintln!(" [托盘] '设置' 被点击"); info!(" [托盘] '设置' 被点击");
if let Some(window) = app.get_webview_window("main") { if let Some(window) = app.get_webview_window("main") {
let _ = window.show(); let _ = window.show();
let _ = window.set_focus(); let _ = window.set_focus();
eprintln!(" ✓ 窗口已显示并聚焦"); info!(" ✓ 窗口已显示并聚焦");
// 导航到设置页面
} }
} }
"quit" => { "theme_light" => {
eprintln!(" [托盘] '退出' 被点击"); info!(" [托盘] '浅色主题' 被点击");
info!("从托盘退出应用"); let state = app.state::<AppState>();
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::<AppState>();
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::<AppState>();
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::<AppState>();
state.allow_exit();
// 关闭窗口
if let Some(window) = app.get_webview_window("main") {
let _ = window.close();
}
// 退出应用
app.exit(0); app.exit(0);
} }
_ => {} _ => {}
@ -310,7 +360,7 @@ fn setup_tray(app: &tauri::App) -> Result<()> {
}) })
.build(app)?; .build(app)?;
eprintln!(" ✓ 托盘图标创建成功"); info!(" ✓ 托盘图标创建成功");
info!("系统托盘设置完成"); info!("系统托盘设置完成");
Ok(()) Ok(())

View File

@ -4,6 +4,33 @@ use crate::config::HistoryEntry;
use parking_lot::RwLock; use parking_lot::RwLock;
use std::collections::VecDeque; 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; const MAX_HISTORY: usize = 100;
@ -18,6 +45,10 @@ pub struct AppState {
history: RwLock<VecDeque<HistoryEntry>>, history: RwLock<VecDeque<HistoryEntry>>,
/// 当前使用的模型名称 /// 当前使用的模型名称
current_model: RwLock<String>, current_model: RwLock<String>,
/// 当前主题
current_theme: RwLock<AppTheme>,
/// 是否允许完全退出
allow_exit: RwLock<bool>,
} }
impl AppState { impl AppState {
@ -85,6 +116,26 @@ impl AppState {
pub fn get_current_model(&self) -> String { pub fn get_current_model(&self) -> String {
self.current_model.read().clone() 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 { impl Clone for AppState {
@ -94,6 +145,8 @@ impl Clone for AppState {
current_recording_path: RwLock::new(self.current_recording_path.read().clone()), current_recording_path: RwLock::new(self.current_recording_path.read().clone()),
history: RwLock::new(self.history.read().clone()), history: RwLock::new(self.history.read().clone()),
current_model: RwLock::new(self.current_model.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()),
} }
} }
} }

View File

@ -4,6 +4,33 @@
box-sizing: border-box; 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 { :root {
--bg-primary: #1a1a2e; --bg-primary: #1a1a2e;
--bg-secondary: #16213e; --bg-secondary: #16213e;
@ -240,3 +267,35 @@ body {
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: #1a4a7a; 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);
}

View File

@ -1,13 +1,69 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import './App.css' import './App.css'
import RecordPage from './pages/RecordPage' import RecordPage from './pages/RecordPage'
import FileConvertPage from './pages/FileConvertPage' import FileConvertPage from './pages/FileConvertPage'
import SettingsPage from './pages/SettingsPage' import SettingsPage from './pages/SettingsPage'
type Page = 'record' | 'file' | 'settings' type Page = 'record' | 'file' | 'settings'
type Theme = 'light' | 'dark' | 'system'
declare const window: Window & {
__TAURI__: {
invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown>
listen: (event: string, handler: (e: { payload: unknown }) => void) => Promise<() => void>
}
}
function App() { function App() {
const [currentPage, setCurrentPage] = useState<Page>('record') const [currentPage, setCurrentPage] = useState<Page>('record')
const [theme, setTheme] = useState<Theme>('system')
// 获取当前主题
useEffect(() => {
const loadTheme = async () => {
try {
const currentTheme = await window.__TAURI__.invoke<string>('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 = () => { const renderPage = () => {
switch (currentPage) { switch (currentPage) {
@ -16,7 +72,7 @@ function App() {
case 'file': case 'file':
return <FileConvertPage /> return <FileConvertPage />
case 'settings': case 'settings':
return <SettingsPage /> return <SettingsPage theme={theme} onThemeChange={handleThemeChange} />
default: default:
return <RecordPage /> return <RecordPage />
} }

View File

@ -15,7 +15,12 @@ interface Settings {
hotkeyToggle: string 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<Settings>({ const [settings, setSettings] = useState<Settings>({
model: 'sensevoice-small', model: 'sensevoice-small',
language: 'zh', language: 'zh',
@ -188,15 +193,26 @@ export default function SettingsPage() {
<div className="setting-item"> <div className="setting-item">
<div className="setting-label"></div> <div className="setting-label"></div>
<select <div className="theme-selector">
className="select" <button
value={settings.theme} className={`theme-btn ${theme === 'light' ? 'active' : ''}`}
onChange={(e) => handleChange('theme', e.target.value)} onClick={() => onThemeChange('light')}
> >
<option value="dark"></option>
<option value="light"></option> </button>
<option value="auto"></option> <button
</select> className={`theme-btn ${theme === 'dark' ? 'active' : ''}`}
onClick={() => onThemeChange('dark')}
>
🌙
</button>
<button
className={`theme-btn ${theme === 'system' ? 'active' : ''}`}
onClick={() => onThemeChange('system')}
>
💻
</button>
</div>
</div> </div>
</div> </div>