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 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);
}

View File

@ -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!(" [事件] 退出请求 - 阻止退出");
// 检查是否允许完全退出
let state = app_handle.state::<AppState>();
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::<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);
}
_ => {}
@ -310,7 +360,7 @@ fn setup_tray(app: &tauri::App) -> Result<()> {
})
.build(app)?;
eprintln!(" ✓ 托盘图标创建成功");
info!(" ✓ 托盘图标创建成功");
info!("系统托盘设置完成");
Ok(())

View File

@ -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<VecDeque<HistoryEntry>>,
/// 当前使用的模型名称
current_model: RwLock<String>,
/// 当前主题
current_theme: RwLock<AppTheme>,
/// 是否允许完全退出
allow_exit: RwLock<bool>,
}
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()),
}
}
}

View File

@ -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);
}

View File

@ -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<string, unknown>) => Promise<unknown>
listen: (event: string, handler: (e: { payload: unknown }) => void) => Promise<() => void>
}
}
function App() {
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 = () => {
switch (currentPage) {
@ -16,7 +72,7 @@ function App() {
case 'file':
return <FileConvertPage />
case 'settings':
return <SettingsPage />
return <SettingsPage theme={theme} onThemeChange={handleThemeChange} />
default:
return <RecordPage />
}

View File

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