feat: 完善托盘功能、完全退出和主题切换
托盘功能: - 添加主题子菜单(浅色/深色/跟随系统) - 添加完全退出菜单项 - 托盘图标使用应用默认图标 完全退出功能: - 新增 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:
parent
ceb2df18c4
commit
7ad06cc54a
@ -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);
|
||||
}
|
||||
|
||||
112
src/app/mod.rs
112
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::<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(())
|
||||
|
||||
@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 />
|
||||
}
|
||||
|
||||
@ -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)}
|
||||
>
|
||||
<option value="dark">深色</option>
|
||||
<option value="light">浅色</option>
|
||||
<option value="auto">跟随系统</option>
|
||||
</select>
|
||||
<div className="theme-selector">
|
||||
<button
|
||||
className={`theme-btn ${theme === 'light' ? 'active' : ''}`}
|
||||
onClick={() => onThemeChange('light')}
|
||||
>
|
||||
☀️ 浅色
|
||||
</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>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user