chore: 初始化项目基础结构和资源文件

- 添加项目图标文件(app-icon.png、各平台图标)
- 配置开发环境文件(.env、.nvmrc、.npmrc)
- 添加静态资源文件(背景图片、字体、音频)
- 初始化Tauri后端结构(build.rs、main.rs、模块文件)
- 配置前端项目结构(TypeScript、Vue组件、样式)
- 添加Node.js API服务基础结构
- 配置构建和开发工具(vite、prettier、gitignore)
This commit is contained in:
2026-03-13 10:03:05 +08:00
commit 78af453fe1
357 changed files with 70605 additions and 0 deletions

View File

@ -0,0 +1,27 @@
//! 应用相关的 Tauri 命令
use tauri::{command, AppHandle, Runtime};
/// 获取应用版本
#[command]
pub fn get_app_version<R: Runtime>(app: AppHandle<R>) -> String {
app.package_info().version.to_string()
}
/// 获取 Tauri 版本
#[command]
pub fn get_tauri_version() -> &'static str {
tauri::VERSION
}
/// 获取应用名称
#[command]
pub fn get_app_name<R: Runtime>(app: AppHandle<R>) -> String {
app.package_info().name.clone()
}
/// 重启应用程序
#[command]
pub fn restart_application<R: Runtime>(app: AppHandle<R>) {
app.restart();
}

View File

@ -0,0 +1,18 @@
/// 初始化应用
///
/// 这个函数在应用启动时被调用,负责设置应用的初始状态
///
/// 注意: SessionStorage 和托盘图标已经在 lib.rs 中通过 .manage() 和 setup() 初始化了,
/// 所以这里不需要重复注册
pub fn initialize_app<R: tauri::Runtime>(_app: &tauri::AppHandle<R>) -> Result<(), Box<dyn std::error::Error>> {
// 创建并设置应用菜单
// 只在 macOS 上有意义,因为 macOS 的应用菜单是独立的
#[cfg(target_os = "macos")]
{
use crate::menu::create_app_menu;
let app_menu = create_app_menu(_app)?;
_app.set_menu(app_menu)?;
}
Ok(())
}

2
src-tauri/src/app/mod.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod commands;
pub mod initializer;

View File

@ -0,0 +1,15 @@
use crate::audio::{play_prompt_tone_imp, stop_prompt_tone_imp};
use tauri::command;
/// 播放提示音的Tauri命令
#[command]
pub fn play_prompt_tone() {
play_prompt_tone_imp();
}
/// 停止音频播放的Tauri命令
#[command]
#[allow(dead_code)] // 允许未使用函数,保留以备将来使用
pub fn stop_prompt_tone() {
stop_prompt_tone_imp();
}

138
src-tauri/src/audio/mod.rs Normal file
View File

@ -0,0 +1,138 @@
use crate::constants::common::*;
use lazy_static::lazy_static;
use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source};
use std::fs::File;
use std::io::{Cursor, Read};
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::mpsc;
use std::thread;
// 导出commands子模块
pub mod commands;
#[derive(Debug)]
enum AudioCommand {
/// ⚡ 使用 Arc 共享音频数据,避免克隆
PlayPromptTone(Arc<Vec<u8>>),
Stop,
}
// 预加载的提示音数据 - 使用 Arc 避免克隆
lazy_static! {
static ref PROMPT_TONE_DATA: Arc<Vec<u8>> = {
// 读取提示音文件
let resource_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join(AUDIO_PROMPT_FILE_PATH);
Arc::new(
File::open(&resource_path)
.ok()
.and_then(|mut file| {
let mut data = Vec::new();
file.read_to_end(&mut data).ok().map(|_| data)
})
.unwrap_or_else(|| {
log::error!("无法读取提示音文件 {:?}", resource_path);
Vec::new()
})
)
};
static ref AUDIO_SYSTEM: AudioSystem = AudioSystem::new();
}
struct AudioResources {
stream: MixerDeviceSink,
}
impl AudioResources {
fn new() -> Result<Self, Box<dyn std::error::Error>> {
// 使用 rodio 0.22 API - DeviceSinkBuilder::open_default_sink()
let stream = DeviceSinkBuilder::open_default_sink()?;
Ok(Self { stream })
}
}
struct AudioSystem {
tx: mpsc::Sender<AudioCommand>,
}
impl AudioSystem {
fn new() -> Self {
let (tx, rx) = mpsc::channel();
thread::spawn(move || Self::audio_worker(rx));
Self { tx }
}
fn audio_worker(rx: mpsc::Receiver<AudioCommand>) {
let audio_resources = match AudioResources::new() {
Ok(resources) => resources,
Err(e) => {
log::error!("音频系统初始化失败: {}", e);
return;
}
};
// 通过 mixer 创建 Player - rodio 0.22 API
let sink = Player::connect_new(audio_resources.stream.mixer());
while let Ok(cmd) = rx.recv() {
match cmd {
AudioCommand::PlayPromptTone(data) => {
// 检查音频数据是否为空
if data.is_empty() {
log::warn!("提示音数据为空");
continue;
}
// 停止当前播放并清空队列
sink.stop();
sink.clear();
// ⚡ 在 worker 线程中克隆数据(只在播放时克隆一次,避免在主线程克隆)
let data_vec = (*data).clone();
// 解码音频数据
match Decoder::new(Cursor::new(data_vec)) {
Ok(source) => {
// 设置音量并播放
let source = source.amplify(AUDIO_VOLUME_AMPLIFICATION);
sink.append(source);
sink.play();
}
Err(e) => {
log::error!("音频解码失败: {:?}", e);
}
}
}
AudioCommand::Stop => {
sink.stop();
}
}
}
// 线程结束时确保停止播放
sink.stop();
}
fn play_prompt_tone(&self) {
// ⚡ 直接克隆 Arc 指针,不再拷贝音频数据
let _ = self.tx.send(AudioCommand::PlayPromptTone(PROMPT_TONE_DATA.clone()));
}
#[allow(dead_code)] // 允许未使用的方法,保留以备将来使用
fn stop(&self) {
let _ = self.tx.send(AudioCommand::Stop);
}
}
/// 播放提示音
pub fn play_prompt_tone_imp() {
AUDIO_SYSTEM.play_prompt_tone();
}
/// 停止音频播放
pub fn stop_prompt_tone_imp() {
AUDIO_SYSTEM.stop();
}

View File

@ -0,0 +1,55 @@
// ============ 应用程序基本信息常量 ============
/// 应用程序名称
pub const APP_NAME: &str = "智评会议";
// ============ 音频常量 ============
/// 音频音量放大倍数
pub const AUDIO_VOLUME_AMPLIFICATION: f32 = 0.2;
/// 提示音文件路径
pub const AUDIO_PROMPT_FILE_PATH: &str = "resources/audio/click_prompt_tone.wav";
// ============ 缩放常量 ============
/// 默认缩放因子100%
pub const DEFAULT_SCALE_FACTOR: f64 = 1.0;
/// 放大倍数(+5%
pub const ZOOM_IN_MULTIPLIER: f64 = 1.05;
/// 缩小倍数(-5%
pub const ZOOM_OUT_MULTIPLIER: f64 = 0.95;
/// 最大缩放级别200%
pub const MAX_ZOOM_LEVEL: f64 = 2.0;
/// 最小缩放级别80%
pub const MIN_ZOOM_LEVEL: f64 = 0.8;
// ============ 应用程序消息常量 ============
/// 主窗口不存在的警告消息
pub const MSG_MAIN_WINDOW_NOT_EXIST: &str = "主窗口不存在,无法设置焦点";
/// 启动定时重启任务失败消息
pub const MSG_FAILED_START_RESTART_TASK: &str = "启动定时重启任务失败: {}";
/// 定时重启任务已启动消息
pub const MSG_RESTART_TASK_STARTED: &str = "定时重启任务凌晨1点已启动";
/// 构建Tauri应用失败消息
pub const MSG_FAILED_BUILD_TAURI: &str = "构建 Tauri 应用程序时出现错误";
/// 创建悬浮球窗口失败消息
pub const MSG_FAILED_CREATE_FLOATING_LIST: &str = "创建悬浮球窗口失败: {}";
/// 创建会议窗口失败消息
pub const MSG_FAILED_CREATE_MEETING_WINDOW: &str = "创建会议窗口失败: {}";
/// 类型2会议窗口需要CourseID的错误消息
pub const MSG_MEETING_TYPE_2_NEEDS_COURSE_ID: &str = "类型2的会议窗口需要提供CourseID";
/// 不支持的会议窗口类型错误消息
pub const MSG_UNSUPPORTED_MEETING_WINDOW_TYPE: &str = "不支持的会议窗口类型";

View File

@ -0,0 +1,3 @@
// 常量模块
pub mod common;
pub mod window;

View File

@ -0,0 +1,56 @@
// ============ 窗口尺寸常量 ============
/// 主窗口默认宽度(像素)
pub const MAIN_WINDOW_WIDTH: f64 = 1200.0;
/// 主窗口默认高度(像素)
pub const MAIN_WINDOW_HEIGHT: f64 = 700.0;
/// 类型1会议窗口宽度像素
pub const MEETING_TYPE_1_WIDTH: f64 = 800.0;
/// 类型1会议窗口高度像素
pub const MEETING_TYPE_1_HEIGHT: f64 = 600.0;
/// 类型2会议窗口宽度像素
pub const MEETING_TYPE_2_WIDTH: f64 = 1400.0;
/// 类型2会议窗口高度像素
pub const MEETING_TYPE_2_HEIGHT: f64 = 800.0;
// ============ 弹出窗口常量 ============
/// 窗口圆角半径
#[cfg(any(target_os = "windows", target_os = "macos"))]
pub const WINDOW_CORNER_RADIUS: f64 = 8.0;
// ============ 放置位置常量 ============
/// 居中放置
pub const PLACEMENT_CENTER: &str = "center";
/// 自动放置
pub const PLACEMENT_AUTO: &str = "auto";
/// 顶部放置
pub const PLACEMENT_TOP: &str = "top";
/// 底部放置
pub const PLACEMENT_BOTTOM: &str = "bottom";
/// 左侧放置
pub const PLACEMENT_LEFT: &str = "left";
/// 右侧放置
pub const PLACEMENT_RIGHT: &str = "right";
// ============ 存储键常量 ============
/// 用户信息存储键
pub const STORAGE_KEY_USER_INFO: &str = "tauri_store_page_user_info";
/// 辅导信息存储键
pub const STORAGE_KEY_TUTORSHIP: &str = "tauri_store_page_curr_tutorship";
/// 课程信息存储键
pub const STORAGE_KEY_COURSE: &str = "tauri_store_page_curr_course";

11
src-tauri/src/env/mod.rs vendored Normal file
View File

@ -0,0 +1,11 @@
//! 环境配置模块
// const TEACHERCONTROL_DEV_URL: &str = "https://jk.qyzhjy.com/TeacherControl_bak.shtml";
const TEACHERCONTROL_DEV_URL: &str = "https://socket.qyzhjy.com/JKList/TeacherControl_bak.shtml";
const TEACHERCONTROL_PROD_URL: &str = "https://jk.qyzhjy.com/TeacherControl_bak.shtml";
/// 实时字幕地址
pub const TEACHERCONTROL_JK_URL: &str = if cfg!(feature = "development") || cfg!(feature = "prod_150_8080") {
TEACHERCONTROL_DEV_URL
} else {
TEACHERCONTROL_PROD_URL
};

227
src-tauri/src/error/mod.rs Normal file
View File

@ -0,0 +1,227 @@
//! 错误处理模块
//!
//! 定义了应用中用到的所有错误类型,支持错误链追踪和上下文信息
use std::sync::Arc;
use thiserror::Error;
/// 应用统一错误类型
///
/// 这个枚举涵盖了应用可能遇到的各种错误情况
/// 使用 thiserror 提供结构化的错误处理和错误链追踪功能
#[derive(Error, Debug)]
pub enum AppError {
/// WebSocket相关错误
#[error("WebSocket错误: {message}")]
WebSocket {
message: String,
#[source]
source: Option<Arc<dyn std::error::Error + Send + Sync>>,
},
/// 序列化错误
#[error("序列化错误: {0}")]
Serialization(#[from] serde_json::Error),
/// IO错误
#[error("IO错误: {0}")]
Io(#[from] std::io::Error),
/// 网络错误
#[error("网络错误: {message}")]
Network {
message: String,
#[source]
source: Option<Arc<dyn std::error::Error + Send + Sync>>,
},
/// 窗口错误
#[error("窗口错误: {message}")]
Window {
message: String,
#[source]
source: Option<Arc<dyn std::error::Error + Send + Sync>>,
},
/// 音频错误
#[error("音频错误: {message}")]
Audio {
message: String,
#[source]
source: Option<Arc<dyn std::error::Error + Send + Sync>>,
},
/// 存储错误
#[error("存储错误: {message}")]
Storage {
message: String,
#[source]
source: Option<Arc<dyn std::error::Error + Send + Sync>>,
},
/// 验证错误
#[error("验证错误: {0}")]
Validation(String),
/// 未找到错误
#[error("未找到: {0}")]
NotFound(String),
/// 状态错误
#[error("状态错误: {0}")]
InvalidState(String),
/// 超时错误
#[error("操作超时: {0}")]
Timeout(String),
/// 取消错误
#[error("操作已取消: {0}")]
Canceled(String),
/// 其他错误
#[error("错误: {message}")]
Other {
message: String,
#[source]
source: Option<Arc<dyn std::error::Error + Send + Sync>>,
},
}
impl AppError {
/// 创建一个 WebSocket 错误
pub fn websocket<M: Into<String>>(message: M) -> Self {
AppError::WebSocket {
message: message.into(),
source: None,
}
}
/// 创建一个带原始错误的 WebSocket 错误
pub fn websocket_with_source<M, E>(message: M, source: E) -> Self
where
M: Into<String>,
E: std::error::Error + Send + Sync + 'static,
{
AppError::WebSocket {
message: message.into(),
source: Some(Arc::new(source)),
}
}
/// 创建一个网络错误
pub fn network<M: Into<String>>(message: M) -> Self {
AppError::Network {
message: message.into(),
source: None,
}
}
/// 创建一个带原始错误的网络错误
pub fn network_with_source<M, E>(message: M, source: E) -> Self
where
M: Into<String>,
E: std::error::Error + Send + Sync + 'static,
{
AppError::Network {
message: message.into(),
source: Some(Arc::new(source)),
}
}
/// 创建一个窗口错误
pub fn window<M: Into<String>>(message: M) -> Self {
AppError::Window {
message: message.into(),
source: None,
}
}
/// 创建一个音频错误
pub fn audio<M: Into<String>>(message: M) -> Self {
AppError::Audio {
message: message.into(),
source: None,
}
}
/// 创建一个存储错误
pub fn storage<M: Into<String>>(message: M) -> Self {
AppError::Storage {
message: message.into(),
source: None,
}
}
/// 创建一个验证错误
pub fn validation<M: Into<String>>(message: M) -> Self {
AppError::Validation(message.into())
}
/// 创建一个未找到错误
pub fn not_found<M: Into<String>>(message: M) -> Self {
AppError::NotFound(message.into())
}
/// 创建一个状态错误
pub fn invalid_state<M: Into<String>>(message: M) -> Self {
AppError::InvalidState(message.into())
}
/// 创建一个超时错误
pub fn timeout<M: Into<String>>(message: M) -> Self {
AppError::Timeout(message.into())
}
/// 创建一个取消错误
pub fn canceled<M: Into<String>>(message: M) -> Self {
AppError::Canceled(message.into())
}
/// 创建一个通用错误
pub fn other<M: Into<String>>(message: M) -> Self {
AppError::Other {
message: message.into(),
source: None,
}
}
/// 给错误添加额外的上下文信息
pub fn context<M: Into<String>>(self, message: M) -> Self {
let msg = format!("{}: {}", message.into(), &self);
match self {
AppError::WebSocket { .. } => AppError::websocket(msg),
AppError::Network { .. } => AppError::network(msg),
AppError::Window { .. } => AppError::window(msg),
AppError::Audio { .. } => AppError::audio(msg),
AppError::Storage { .. } => AppError::storage(msg),
_ => AppError::other(msg),
}
}
}
/// 把 AppError 转换成字符串
/// 主要用于 Tauri 命令返回值
impl From<AppError> for String {
fn from(error: AppError) -> Self {
error.to_string()
}
}
/// 从字符串创建 AppError
/// 为了兼容旧代码保留这个转换
impl From<String> for AppError {
fn from(message: String) -> Self {
AppError::other(message)
}
}
/// 从字符串切片创建 AppError
/// 为了兼容旧代码保留这个转换
impl From<&str> for AppError {
fn from(message: &str) -> Self {
AppError::other(message.to_string())
}
}

View File

@ -0,0 +1,46 @@
//! Tauri 命令处理器
//! 集中管理所有 Tauri 命令的注册和处理
//!
use crate::storage::session_storage::*;
use crate::app::commands::*;
use crate::audio::commands::*;
use crate::tray::commands::*;
use crate::window::commands::*;
use tauri::Builder;
/// 注册所有 Tauri 命令
///
/// 将所有可用的 Tauri 命令注册到应用构建器中
///
/// # 参数
/// * `builder` - Tauri 应用构建器
///
/// # 返回值
/// 返回配置了所有命令的应用构建器
pub fn register_commands(builder: Builder<tauri::Wry>) -> Builder<tauri::Wry> {
builder.invoke_handler(tauri::generate_handler![
// 会话存储相关命令
session_storage_get,
session_storage_set,
session_storage_remove,
session_storage_clear,
session_storage_keys,
session_storage_has,
// 应用相关命令
get_app_version,
get_tauri_version,
get_app_name,
restart_application,
// 音频相关命令
play_prompt_tone,
stop_prompt_tone,
// 窗口相关命令
create_float_list_window,
create_popup_window,
create_main_window,
create_meeting_window,
// 系统托盘相关命令
update_tray_menu,
])
}

View File

@ -0,0 +1 @@
pub mod handlers;

144
src-tauri/src/lib.rs Normal file
View File

@ -0,0 +1,144 @@
// 下面引入了应用的各个功能模块
// app - 应用的初始化和启动相关逻辑
// audio - 音频播放和声音处理
// constants - 常量定义
// env - 环境变量和配置读取
// error - 错误类型和错误处理
// invoke - Tauri 命令处理器,处理前端调用
// menu - 应用菜单配置和事件处理
// models - 公共数据结构定义
// storage - 数据持久化存储
// task - 定时任务和计划任务
// tray - 系统托盘图标和菜单
// utils - 通用工具函数
// window - 窗口创建和管理
mod app;
mod audio;
mod constants;
mod env;
mod error;
mod invoke;
mod menu;
mod storage;
mod task;
mod tray;
mod utils;
pub mod window;
// 引入各个模块的类型和功能
use crate::constants::common::*;
use crate::window::{WindowLabel, util::show_windows_by_label};
use app::initializer::initialize_app;
use invoke::handlers::register_commands;
use menu::menu_handler::handle_menu_event;
use storage::session_storage::SessionStorage;
use task::scheduled_restart::start_scheduled_restart_task;
use tauri::Manager;
use tauri::RunEvent;
use tray::setup_tray;
/// 移动平台入口点
/// 在移动设备上运行时需要这个属性标记
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let builder = tauri::Builder::default()
.plugin(tauri_plugin_websocket::init())
// 下面加载各种 Tauri 插件
// os 插件 - 获取操作系统信息
.plugin(tauri_plugin_os::init())
// dialog 插件 - 显示文件选择对话框等
.plugin(tauri_plugin_dialog::init())
// fs 插件 - 文件系统访问
.plugin(tauri_plugin_fs::init())
// persisted_scope 插件 - 记住文件访问权限
.plugin(tauri_plugin_persisted_scope::init())
// process 插件 - 进程相关信息
.plugin(tauri_plugin_process::init())
// updater 插件 - 应用自动更新
.plugin(tauri_plugin_updater::Builder::new().build())
// http 插件 - HTTP 客户端请求
.plugin(tauri_plugin_http::init())
// single_instance 插件 - 保证只启动一个应用实例
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
let app_handle = app.clone();
tauri::async_runtime::spawn(async move {
let main_label = WindowLabel::MAIN.as_ref();
if app_handle.get_webview_window(main_label).is_some() {
show_windows_by_label(&app_handle, WindowLabel::MAIN);
} else {
log::warn!("{}", MSG_MAIN_WINDOW_NOT_EXIST);
}
});
}))
// 注册全局状态管理
.manage(SessionStorage::new())
// 下面是应用初始化和事件处理的设置
// setup 函数会在应用构建完成后执行一次
.setup(|app| {
// 初始化应用,包括创建窗口、设置初始状态等
initialize_app(app.handle())?;
// 创建系统托盘图标
setup_tray(app.handle())?;
// 启动每日定时重启任务
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = start_scheduled_restart_task(app_handle).await {
log::error!("{} {}", MSG_FAILED_START_RESTART_TASK, e);
} else {
log::info!("{}", MSG_RESTART_TASK_STARTED);
}
});
Ok(())
})
// 处理菜单点击事件
// 当用户点击菜单项时会触发这个回调
.on_menu_event(|app, event| {
handle_menu_event(app, event.id.as_ref());
});
// 注册 Tauri 命令处理器
// 这些命令会被前端通过 invoke 调用
let builder = register_commands(builder);
let app = builder
// 构建应用上下文
.build(tauri::generate_context!())
// 如果构建失败,输出错误信息并退出
.unwrap_or_else(|e| {
log::error!("{}: {:?}", MSG_FAILED_BUILD_TAURI, e);
std::process::exit(1);
});
// 启动应用并处理系统事件
app.run(|_app_handle: &tauri::AppHandle, event| {
match event {
// 应用退出时触发(无法阻止,确保一定执行)
RunEvent::Exit => {
log::info!("应用正在退出,执行资源清理...");
}
// 处理 macOS 上的 Reopen 事件
// 当用户点击 Dock 图标时触发
#[cfg(target_os = "macos")]
RunEvent::Reopen { .. } => {
// 获取主窗口
if let Some(main_window) = _app_handle.get_webview_window(WindowLabel::MAIN.as_ref()) {
// 如果窗口被最小化了,就恢复它
if main_window.is_minimized().unwrap_or(false) && main_window.unminimize().is_err() {
log::warn!("恢复主窗口失败");
}
// 显示窗口并放到前台
if let Err(e) = main_window.show() {
log::warn!("显示主窗口失败: {:?}", e);
}
if let Err(e) = main_window.set_focus() {
log::warn!("设置主窗口焦点失败: {:?}", e);
}
}
}
_ => {}
}
});
}

6
src-tauri/src/main.rs Normal file
View File

@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
zhipinghuiyi_lib::run();
}

View File

@ -0,0 +1,47 @@
/// 创建应用菜单(中文)
#[cfg(target_os = "macos")]
pub fn create_app_menu<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<tauri::menu::Menu<R>> {
use tauri::{
image::Image,
menu::{AboutMetadataBuilder, MenuBuilder, SubmenuBuilder},
};
let default_name = "智评会议";
let app_name = app.config().product_name.as_deref().unwrap_or(default_name);
// 创建视图菜单
let view_submenu = SubmenuBuilder::new(app, "视图")
.text("zoom_in", "放大")
.text("zoom_out", "缩小")
.text("zoom_reset", "实际大小")
.separator()
.text("refresh", "刷新页面")
.build()?;
// 创建窗口菜单
let window_submenu = SubmenuBuilder::new(app, "窗口")
.minimize_with_text("最小化")
.maximize_with_text("最大化")
.separator()
.close_window_with_text("关闭")
.build()?;
// 应用菜单(跨平台)
let about_metadata = AboutMetadataBuilder::new()
.copyright(Some("Copyright © 2025 智评会议".to_string()))
.authors(Some(vec!["重庆树人教育研究院".to_string()]))
.website_label(Some("https://www.zpingketang.com".to_string()))
.icon(Image::from_bytes(include_bytes!("../../icons/icon.png")).ok())
.build();
let app_submenu = SubmenuBuilder::new(app, app_name)
.about_with_text("关于应用", Some(about_metadata))
.separator()
.quit_with_text("退出应用程序")
.build()?;
// 构建完整菜单
let menu = MenuBuilder::new(app).items(&[&app_submenu, &view_submenu, &window_submenu]).build()?;
Ok(menu)
}

View File

@ -0,0 +1,259 @@
use crate::constants::common::*;
use crate::window::WindowLabel;
use parking_lot::Mutex;
use std::collections::HashMap;
use tauri::{AppHandle, Manager, Runtime};
// 使用lazy_static宏创建全局静态变量用于存储各个窗口的缩放因子
// parking_lot::Mutex 确保线程安全,性能更佳,不会返回 Result
// HashMap存储窗口标签和对应的缩放因子
lazy_static::lazy_static! {
/// 全局窗口缩放因子映射表
/// key: 窗口标签(WindowLabel)
/// value: 缩放因子(f64)
static ref WINDOW_SCALE_FACTORS: Mutex<HashMap<WindowLabel, f64>> = Mutex::new(HashMap::new());
}
/// 获取指定窗口的当前缩放级别
///
/// # 参数
/// * `window_label` - 窗口标签
///
/// # 返回值
/// 返回窗口当前的缩放因子默认为1.0(100%)
fn get_window_scale(window_label: WindowLabel) -> f64 {
WINDOW_SCALE_FACTORS.lock().get(&window_label).copied().unwrap_or(DEFAULT_SCALE_FACTOR)
}
/// 设置指定窗口的缩放级别
///
/// # 参数
/// * `window_label` - 窗口标签
/// * `scale` - 新的缩放因子
fn set_window_scale(window_label: WindowLabel, scale: f64) {
let mut scales = WINDOW_SCALE_FACTORS.lock();
scales.insert(window_label, scale);
}
/// 处理窗口放大事件
///
/// # 功能说明
/// 1. 获取主窗口对象
/// 2. 获取当前窗口缩放级别
/// 3. 计算新的缩放级别(增加5%)
/// 4. 限制最大缩放为2倍(200%)
/// 5. 更新窗口缩放级别并应用到窗口
///
/// # 参数
/// * `app` - Tauri应用句柄
pub fn handle_zoom_in<R: Runtime>(app: &AppHandle<R>) {
// 获取标签为"main"的窗口对象
let label = WindowLabel::MAIN.as_ref();
if let Some(window) = app.get_webview_window(label) {
let window_label = WindowLabel::MAIN;
// 在异步运行时中执行缩放逻辑,避免阻塞主线程
tauri::async_runtime::spawn(async move {
// 获取当前窗口缩放级别
let current_scale = get_window_scale(window_label);
// 计算新的缩放级别 (每次放大5%)
let new_scale = current_scale * ZOOM_IN_MULTIPLIER;
// 限制最大缩放为2倍
let clamped_scale = new_scale.min(MAX_ZOOM_LEVEL);
// 设置新的缩放级别
set_window_scale(window_label, clamped_scale);
// 应用缩放到窗口
if let Err(e) = window.set_zoom(clamped_scale) {
log::error!("设置窗口缩放失败: {}", e);
}
});
}
}
/// 处理窗口缩小事件
///
/// # 功能说明
/// 1. 获取主窗口对象
/// 2. 获取当前窗口缩放级别
/// 3. 计算新的缩放级别(减少5%)
/// 4. 限制最小缩放为0.8倍(80%)
/// 5. 更新窗口缩放级别并应用到窗口
///
/// # 参数
/// * `app` - Tauri应用句柄
pub fn handle_zoom_out<R: Runtime>(app: &AppHandle<R>) {
// 获取标签为"main"的窗口对象
let label = WindowLabel::MAIN.as_ref();
if let Some(window) = app.get_webview_window(label) {
let window_label = WindowLabel::MAIN;
// 在异步运行时中执行缩放逻辑,避免阻塞主线程
tauri::async_runtime::spawn(async move {
// 获取当前窗口缩放级别
let current_scale = get_window_scale(window_label);
// 计算新的缩放级别 (每次缩小5%)
let new_scale = current_scale * ZOOM_OUT_MULTIPLIER;
// 限制最小缩放为0.8倍
let clamped_scale = new_scale.max(MIN_ZOOM_LEVEL);
// 设置新的缩放级别
set_window_scale(window_label, clamped_scale);
// 应用缩放到窗口
if let Err(e) = window.set_zoom(clamped_scale) {
log::error!("设置窗口缩放失败: {}", e);
}
});
}
}
/// 处理窗口重置缩放事件
///
/// # 功能说明
/// 1. 获取主窗口对象
/// 2. 将窗口缩放级别重置为默认值(1.0)
/// 3. 更新窗口缩放级别并应用到窗口
///
/// # 参数
/// * `app` - Tauri应用句柄
pub fn handle_zoom_reset<R: Runtime>(app: &AppHandle<R>) {
// 获取标签为"main"的窗口对象
let label = WindowLabel::MAIN.as_ref();
if let Some(window) = app.get_webview_window(label) {
let window_label = WindowLabel::MAIN;
// 在异步运行时中执行缩放逻辑,避免阻塞主线程
tauri::async_runtime::spawn(async move {
// 重置缩放为1.0 (默认大小)
set_window_scale(window_label, DEFAULT_SCALE_FACTOR);
if let Err(e) = window.set_zoom(DEFAULT_SCALE_FACTOR) {
log::error!("重置窗口缩放失败: {}", e);
}
});
}
}
/// 处理窗口最小化事件
///
/// # 功能说明
/// 将主窗口最小化到任务栏或dock
///
/// # 参数
/// * `app` - Tauri应用句柄
pub fn handle_minimize<R: Runtime>(app: &AppHandle<R>) {
// 获取标签为"main"的窗口对象
let label = WindowLabel::MAIN.as_ref();
if let Some(window) = app.get_webview_window(label) {
// 在异步运行时中执行最小化操作,避免阻塞主线程
tauri::async_runtime::spawn(async move {
if let Err(e) = window.minimize() {
log::error!("最小化窗口失败: {}", e);
}
});
}
}
/// 处理窗口还原事件
///
/// # 功能说明
/// 将主窗口从最小化状态还原到正常状态
///
/// # 参数
/// * `app` - Tauri应用句柄
pub fn handle_restore<R: Runtime>(app: &AppHandle<R>) {
let label = WindowLabel::MAIN.as_ref();
if let Some(window) = app.get_webview_window(label) {
tauri::async_runtime::spawn(async move {
if let Err(e) = window.unminimize() {
log::error!("还原窗口失败: {}", e);
}
});
}
}
/// 处理窗口最大化事件
///
/// # 功能说明
/// 切换主窗口的最大化状态
///
/// # 参数
/// * `app` - Tauri应用句柄
pub fn handle_maximize<R: Runtime>(app: &AppHandle<R>) {
let label = WindowLabel::MAIN.as_ref();
if let Some(window) = app.get_webview_window(label) {
// 切换最大化状态
match window.is_maximized() {
Ok(is_maximized) => {
if let Err(e) = if is_maximized { window.unmaximize() } else { window.maximize() } {
log::error!("切换窗口最大化状态失败: {}", e);
}
}
Err(e) => {
log::error!("获取窗口最大化状态失败: {}", e);
}
}
}
}
/// 处理窗口关闭事件
///
/// # 功能说明
/// 关闭主窗口
///
/// # 参数
/// * `app` - Tauri应用句柄
pub fn handle_close<R: Runtime>(app: &AppHandle<R>) {
let label = WindowLabel::MAIN.as_ref();
if let Some(window) = app.get_webview_window(label)
&& let Err(e) = window.close()
{
log::error!("关闭窗口失败: {}", e);
}
}
/// 处理窗口全屏事件
///
/// # 功能说明
/// 切换主窗口的全屏状态
///
/// # 参数
/// * `app` - Tauri应用句柄
pub fn handle_fullscreen<R: Runtime>(app: &AppHandle<R>) {
let label = WindowLabel::MAIN.as_ref();
if let Some(window) = app.get_webview_window(label) {
// 切换全屏状态
match window.is_fullscreen() {
Ok(is_fullscreen) => {
if let Err(e) = window.set_fullscreen(!is_fullscreen) {
log::error!("切换窗口全屏状态失败: {}", e);
}
}
Err(e) => {
log::error!("获取窗口全屏状态失败: {}", e);
}
}
}
}
/// 处理窗口刷新事件
///
/// # 功能说明
/// 刷新主窗口的页面内容
///
/// # 参数
/// * `app` - Tauri应用句柄
pub fn handle_refresh<R: Runtime>(app: &AppHandle<R>) {
let label = WindowLabel::MAIN.as_ref();
if let Some(window) = app.get_webview_window(label)
&& let Err(e) = window.eval("window.location.reload()")
{
log::error!("刷新页面失败: {}", e);
}
}

View File

@ -0,0 +1,19 @@
use crate::menu::menu_event::{
handle_close, handle_fullscreen, handle_maximize, handle_minimize, handle_refresh, handle_restore, handle_zoom_in, handle_zoom_out, handle_zoom_reset,
};
/// 处理菜单事件
pub fn handle_menu_event<R: tauri::Runtime>(app: &tauri::AppHandle<R>, event_id: &str) {
match event_id {
"zoom_in" => handle_zoom_in(app),
"zoom_out" => handle_zoom_out(app),
"zoom_reset" => handle_zoom_reset(app),
"minimize" => handle_minimize(app),
"restore" => handle_restore(app),
"maximize" => handle_maximize(app),
"close" => handle_close(app),
"fullscreen" => handle_fullscreen(app),
"refresh" => handle_refresh(app),
_ => {}
}
}

View File

@ -0,0 +1,6 @@
pub mod app_menu;
pub mod menu_event;
pub mod menu_handler;
#[cfg(target_os = "macos")]
pub use app_menu::create_app_menu;

View File

@ -0,0 +1,4 @@
pub mod session_storage;
// 重新导出存储结构和命令
pub use session_storage::SessionStorage;

View File

@ -0,0 +1,73 @@
use super::SessionStorage;
use tauri::{AppHandle, Emitter, EventTarget, State, Window};
// 获取会话存储的值
#[tauri::command]
pub fn session_storage_get(key: String, storage: State<SessionStorage>) -> Option<serde_json::Value> {
storage.get(&key).and_then(|result| result.ok())
}
// 设置会话存储的值
#[tauri::command]
pub fn session_storage_set(key: String, value: serde_json::Value, storage: State<SessionStorage>, app: AppHandle, window: Window) -> bool {
let result = storage.set(&key, &value).is_ok();
if result
&& let Err(e) = app.emit_filter("session_storage_changed", (key, &value), |target| {
match target {
// 只有当窗口的 label 不是调用窗口的 label 时,才发送事件
EventTarget::WebviewWindow { label } => label != window.label(),
_ => false,
}
}) {
log::warn!("发送存储变更事件失败: {}", e);
}
result
}
// 从会话存储中移除值
#[tauri::command]
pub fn session_storage_remove(key: String, storage: State<SessionStorage>, app: AppHandle, window: Window) -> bool {
let result = storage.remove(&key);
if result {
// 向除调用窗口外的所有窗口发送数据删除事件
if let Err(e) = app.emit_filter("session_storage_removed", key, |target| {
match target {
// 只有当窗口的 label 不是调用窗口的 label 时,才发送事件
EventTarget::WebviewWindow { label } => label != window.label(),
_ => false,
}
}) {
log::warn!("发送存储删除事件失败: {}", e);
}
}
result
}
// 清空会话存储
#[tauri::command]
pub fn session_storage_clear(storage: State<SessionStorage>, app: AppHandle, window: Window) -> bool {
storage.clear();
// 向除调用窗口外的所有窗口发送清空事件
if let Err(e) = app.emit_filter("session_storage_cleared", (), |target| {
match target {
// 只有当窗口的 label 不是调用窗口的 label 时,才发送事件
EventTarget::WebviewWindow { label } => label != window.label(),
_ => false,
}
}) {
log::warn!("发送存储清空事件失败: {}", e);
}
true
}
// 获取所有会话存储键
#[tauri::command]
pub fn session_storage_keys(storage: State<SessionStorage>) -> Vec<String> {
storage.keys()
}
// 检查会话存储中是否存在指定键
#[tauri::command]
pub fn session_storage_has(key: String, storage: State<SessionStorage>) -> bool {
storage.has(&key)
}

View File

@ -0,0 +1,4 @@
mod storage;
pub use storage::SessionStorage;
mod commands;
pub use commands::*;

View File

@ -0,0 +1,123 @@
//! 会话存储模块
//!
//! 提供高性能的会话数据存储实现,使用零拷贝操作来提升性能
use crate::utils::zero_copy::ZeroCopyBytes;
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use serde_json;
use std::collections::HashMap;
use std::fmt;
use std::sync::Arc;
/// 会话存储错误类型
#[derive(Debug)]
pub enum SessionStorageError {
/// 序列化时出错
SerializationError(serde_json::Error),
/// 反序列化时出错
DeserializationError(serde_json::Error),
}
impl fmt::Display for SessionStorageError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SessionStorageError::SerializationError(e) => write!(f, "序列化时出错: {}", e),
SessionStorageError::DeserializationError(e) => write!(f, "反序列化时出错: {}", e),
}
}
}
impl std::error::Error for SessionStorageError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
SessionStorageError::SerializationError(e) => Some(e),
SessionStorageError::DeserializationError(e) => Some(e),
}
}
}
impl From<serde_json::Error> for SessionStorageError {
fn from(err: serde_json::Error) -> Self {
SessionStorageError::SerializationError(err)
}
}
/// 会话存储数据结构
///
/// 使用 ZeroCopyBytes 存储序列化后的数据,避免不必要的内存拷贝
#[derive(Debug, Clone)]
pub struct SessionStorage {
/// 使用 ZeroCopyBytes 代替 String,减少内存分配
data: Arc<RwLock<HashMap<String, ZeroCopyBytes>>>,
}
impl SessionStorage {
/// 创建一个新的会话存储实例
pub fn new() -> Self {
Self {
data: Arc::new(RwLock::new(HashMap::new())),
}
}
/// 存储任意类型的值
///
/// 值会被序列化成 JSON 格式存储
///
/// 性能优化: 使用 serde_json::to_vec 而不是 to_string,可以减少一次 UTF-8 验证
pub fn set<T: Serialize>(&self, key: &str, value: &T) -> Result<(), SessionStorageError> {
// 序列化为 JSON 字节,比 to_string 稍快一点
let json_bytes = serde_json::to_vec(value)?;
// 使用 ZeroCopyBytes 包装,避免额外的字符串分配
let zero_copy_bytes = ZeroCopyBytes::new(json_bytes);
// 写入数据
self.data.write().insert(key.to_owned(), zero_copy_bytes);
Ok(())
}
/// 获取存储的值并反序列化成指定类型
pub fn get<T: for<'de> Deserialize<'de>>(&self, key: &str) -> Option<Result<T, SessionStorageError>> {
// 优化在锁内只进行克隆操作ZeroCopyBytes 是 cheap clone将耗时的反序列化移出锁外
let zero_copy_bytes = {
let data_guard = self.data.read();
data_guard.get(key).cloned()
};
if let Some(zero_copy_bytes) = zero_copy_bytes {
// 在锁外进行反序列化
match serde_json::from_slice(zero_copy_bytes.as_slice()) {
Ok(value) => Some(Ok(value)),
Err(err) => Some(Err(SessionStorageError::DeserializationError(err))),
}
} else {
None
}
}
/// 移除指定键的值
pub fn remove(&self, key: &str) -> bool {
self.data.write().remove(key).is_some()
}
/// 清空所有存储的数据
pub fn clear(&self) {
self.data.write().clear();
}
/// 获取所有存储的键
pub fn keys(&self) -> Vec<String> {
self.data.read().keys().cloned().collect()
}
/// 检查键是否存在
pub fn has(&self, key: &str) -> bool {
self.data.read().contains_key(key)
}
}
impl Default for SessionStorage {
fn default() -> Self {
Self::new()
}
}

View File

@ -0,0 +1 @@
pub mod scheduled_restart;

View File

@ -0,0 +1,51 @@
use once_cell::sync::OnceCell;
use tauri::AppHandle;
use tokio_cron_scheduler::{Job, JobScheduler};
use tracing::info;
/// 全局调度器实例
/// 使用 OnceCell 确保只创建一次,并在应用生命周期内保持活动
static SCHEDULER: OnceCell<JobScheduler> = OnceCell::new();
/// 启动每日凌晨1点定时重启任务
///
/// 注意:此函数会启动一个后台任务,调度器会在后台持续运行
/// 调度器的生命周期与应用程序相同
pub async fn start_scheduled_restart_task(app_handle: AppHandle) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// 如果调度器已经存在,直接返回
if SCHEDULER.get().is_some() {
info!("定时重启任务已经启动,跳过重复初始化");
return Ok(());
}
// 创建调度器
let scheduler = JobScheduler::new().await?;
// 创建每天凌晨1点执行的定时任务
// cron表达式: "0 0 1 * * *" 表示每天1点0分0秒执行
// 格式: sec min hour day_of_month month day_of_week
let job = Job::new_async("0 0 1 * * *", move |_uuid, _l| {
let app_handle_clone = app_handle.clone();
Box::pin(async move {
info!("定时重启任务已触发,正在重启应用程序...");
// 重启应用
app_handle_clone.restart();
// 注意:由于重启操作会终止程序,下面的代码不会执行
})
})?;
// 添加任务到调度器
scheduler.add(job).await?;
// 启动调度器
scheduler.start().await?;
// 将调度器保存到全局静态变量中,确保其不会被清理
SCHEDULER.set(scheduler).map_err(|_| "定时任务调度器已初始化")?;
info!("定时重启任务已启动 - 应用程序将在每天凌晨1点重启");
Ok(())
}

View File

@ -0,0 +1,18 @@
// 系统托盘相关命令
use tauri::AppHandle;
// 更新托盘菜单命令
// Linux平台会实际更新托盘菜单其他平台为空实现
#[tauri::command]
pub async fn update_tray_menu(_app: AppHandle) -> Result<(), String> {
// 只在Linux平台上实际更新托盘菜单
#[cfg(target_os = "linux")]
{
if let Err(e) = crate::tray::linux_tray::rebuild_tray_menu(&_app).await {
return Err(format!("更新托盘菜单失败: {}", e));
}
}
// 非Linux平台不支持动态更新托盘菜单直接返回成功
Ok(())
}

View File

@ -0,0 +1,83 @@
use crate::audio::play_prompt_tone_imp;
use crate::window::{WindowLabel, create_popup_window::create_popup_window_impl, derive::TargetLocation};
use tauri::{AppHandle, Runtime, tray::TrayIconBuilder};
/// 创建并设置系统托盘图标
///
/// 该函数会检查是否已存在相同ID的托盘图标以避免重复添加
/// 并为托盘图标绑定点击事件处理函数,点击时直接打开弹出框窗口。
pub fn setup_tray<R: Runtime>(app: &AppHandle<R>) -> Result<(), String> {
let tray_id = WindowLabel::MAIN_TRAY.as_ref();
// 检查是否已经存在托盘图标,避免重复添加
if app.tray_by_id(tray_id).is_some() {
return Ok(());
}
// 获取默认窗口图标
let icon = app.default_window_icon().ok_or("获取默认窗口图标失败")?.clone();
// 创建托盘图标(不关联菜单)
use std::sync::Arc;
let app_shared = Arc::new(app.clone());
TrayIconBuilder::with_id(tray_id)
.icon(icon)
.tooltip("智评会议")
.on_tray_icon_event(move |_tray, event| {
// 处理托盘图标点击事件
if let tauri::tray::TrayIconEvent::Click {
button_state: tauri::tray::MouseButtonState::Up,
rect,
..
} = event
{
let app_handle = Arc::clone(&app_shared);
// 如果popup窗口不存在则创建并显示弹出框窗口
tauri::async_runtime::spawn(async move {
// 获取主显示器的缩放因子
let scale_factor = (*app_handle).primary_monitor().ok().flatten().map(|monitor| monitor.scale_factor()).unwrap_or(1.0);
let target = get_target_location(&rect, scale_factor);
// 直接调用后端函数创建弹出框窗口
let _ = create_popup_window_impl(&app_handle, &target, "auto").await;
play_prompt_tone_imp();
});
}
})
.build(app)
.map_err(|e| format!("创建托盘图标失败: {}", e))?;
// 托盘图标创建成功
Ok(())
}
/**
* 获取目标位置信息
*/
fn get_target_location(rect: &tauri::Rect, scale_factor: f64) -> TargetLocation {
// 使用scale_factor将物理坐标转换为逻辑坐标
// 在托盘图标的上下文中默认使用1.0作为scale_factor
// 这样可以确保返回的坐标始终是逻辑坐标
let x = match rect.position {
tauri::Position::Physical(physical_position) => (physical_position.x as f64 / scale_factor) as i32,
tauri::Position::Logical(logical_position) => logical_position.x as i32,
};
let y = match rect.position {
tauri::Position::Physical(physical_position) => (physical_position.y as f64 / scale_factor) as i32,
tauri::Position::Logical(logical_position) => logical_position.y as i32,
};
let width = match rect.size {
tauri::Size::Physical(physical_size) => (physical_size.width as f64 / scale_factor) as u32,
tauri::Size::Logical(logical_size) => logical_size.width as u32,
};
let height = match rect.size {
tauri::Size::Physical(physical_size) => (physical_size.height as f64 / scale_factor) as u32,
tauri::Size::Logical(logical_size) => logical_size.height as u32,
};
TargetLocation { x, y, width, height }
}

View File

@ -0,0 +1,134 @@
//! Linux系统托盘菜单实现
//!
//! Linux系统托盘的特殊实现因为Linux系统托盘的行为与其他平台不同
//! 需要专门处理以确保托盘图标和菜单能正常显示。
use crate::audio::play_prompt_tone_imp;
use crate::window::{WindowLabel, create_drawing_board_window::create_drawing_board_window_impl, create_main_window::create_main_window_impl};
use tauri::menu::{ContextMenu, MenuBuilder, MenuItemBuilder};
use tauri::{AppHandle, Runtime, tray::TrayIconBuilder};
/// 创建并设置Linux系统托盘图标和菜单
///
/// Linux系统托盘需要特殊处理因为Linux系统托盘的行为与其他平台不同
pub fn setup_linux_tray<R: Runtime>(app: &AppHandle<R>) -> Result<(), Box<dyn std::error::Error>> {
let tray_id = WindowLabel::MAIN_TRAY.as_ref();
// 检查是否已经存在托盘图标,避免重复添加
if app.tray_by_id(tray_id).is_some() {
return Ok(());
}
// 创建托盘菜单
let tray_menu = create_tray_menu(app)?;
// 获取默认窗口图标
let icon = app.default_window_icon().ok_or_else(|| "获取默认窗口图标失败".to_string())?.clone();
// 创建托盘图标(关联菜单)
let tray_icon = TrayIconBuilder::with_id(tray_id)
.icon(icon)
.tooltip("智评会议")
.menu(&tray_menu)
.build(app)
.map_err(|e| format!("创建托盘图标失败: {}", e))?;
// 使用 on_menu_event 处理所有菜单事件
tray_icon.on_menu_event(move |app, event| {
let app_handle = app.clone();
match event.id.as_ref() {
"main_window" => {
play_prompt_tone_imp();
// 切换主窗口显示/隐藏状态
tauri::async_runtime::spawn(async move {
toggle_window_visibility(&app_handle, WindowLabel::MAIN).await;
});
}
"drawing_board" => {
play_prompt_tone_imp();
// 切换画板窗口显示/隐藏状态
tauri::async_runtime::spawn(async move {
toggle_window_visibility(&app_handle, WindowLabel::DRAWING_BOARD).await;
});
}
"quit" => {
play_prompt_tone_imp();
tauri::async_runtime::spawn(async move {
// 直接退出RunEvent::ExitRequested 会处理清理
app_handle.exit(0);
});
}
_ => {}
}
});
// 托盘图标创建成功
Ok(())
}
/// 创建托盘上下文菜单
fn create_tray_menu<R: Runtime>(app: &AppHandle<R>) -> Result<impl ContextMenu, tauri::Error> {
// 创建菜单项
let main_window_item = MenuItemBuilder::with_id("main_window", "显示主窗口").build(app)?;
let drawing_board_item = MenuItemBuilder::with_id("drawing_board", "显示画板").build(app)?;
let quit_item = MenuItemBuilder::with_id("quit", "退出应用程序").build(app)?;
// 构建菜单
let menu_builder = MenuBuilder::new(app).item(&main_window_item).item(&drawing_board_item);
// 添加退出项
let menu = menu_builder.separator().item(&quit_item).build()?;
Ok(menu)
}
/// 切换窗口显示/隐藏状态
async fn toggle_window_visibility<R: Runtime>(app: &AppHandle<R>, window_label: WindowLabel) {
// 窗口不存在,创建窗口
create_window(app, window_label).await;
// 重建菜单以更新菜单项文字
// let _ = rebuild_tray_menu(&app);
}
/// 创建窗口
async fn create_window<R: Runtime>(app: &AppHandle<R>, window_label: WindowLabel) {
match window_label {
WindowLabel::MAIN => {
if let Err(e) = create_main_window_impl(app).await {
log::error!("创建主窗口失败: {}", e);
// 可以考虑显示错误对话框或通知用户
}
}
WindowLabel::DRAWING_BOARD => {
if let Err(e) = create_drawing_board_window_impl(app).await {
log::error!("创建画板窗口失败: {}", e);
// 可以考虑显示错误对话框或通知用户
}
}
_ => {
log::error!("未知窗口标签: {}", window_label);
}
}
}
/// 重建托盘菜单
pub async fn rebuild_tray_menu<R: Runtime>(app: &AppHandle<R>) -> Result<(), Box<dyn std::error::Error>> {
// 克隆 app handle 以解决生命周期问题
let app_handle = app.clone();
// 获取托盘图标并更新菜单
if let Some(tray_icon) = app.tray_by_id(WindowLabel::MAIN_TRAY.as_ref()) {
// 创建新的托盘菜单
let main_window_item = MenuItemBuilder::with_id("main_window", "显示主窗口").build(&app_handle)?;
let drawing_board_item = MenuItemBuilder::with_id("drawing_board", "显示画板").build(&app_handle)?;
let quit_item = MenuItemBuilder::with_id("quit", "退出应用程序").build(&app_handle)?;
// 构建菜单
let menu = MenuBuilder::new(&app_handle)
.item(&main_window_item)
.item(&drawing_board_item)
.separator()
.item(&quit_item)
.build()?;
tray_icon.set_menu(Some(menu)).map_err(|e| format!("设置托盘菜单失败: {}", e))?;
}
Ok(())
}

13
src-tauri/src/tray/mod.rs Normal file
View File

@ -0,0 +1,13 @@
// 条件编译导入模块
pub mod commands;
#[cfg(target_os = "linux")]
mod linux_tray;
#[cfg(target_os = "linux")]
pub use linux_tray::setup_linux_tray as setup_tray;
#[cfg(not(target_os = "linux"))]
mod common_tray;
#[cfg(not(target_os = "linux"))]
pub use common_tray::setup_tray;

View File

@ -0,0 +1,5 @@
//! 工具模块
//!
//! 提供各种工具函数和零拷贝优化
pub mod zero_copy;

View File

@ -0,0 +1,69 @@
//! 零拷贝优化模块
//!
//! 提供零拷贝数据结构和工具函数,避免不必要的内存分配和数据拷贝
use std::ops::Deref;
/// 零拷贝字节数据包装器
///
/// 使用 `Bytes` 替代 `String` 和 `Vec<u8>`,实现零拷贝数据传递
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ZeroCopyBytes(pub bytes::Bytes);
impl ZeroCopyBytes {
/// 创建新的零拷贝字节数据
#[inline]
pub fn new<B: Into<bytes::Bytes>>(bytes: B) -> Self {
Self(bytes.into())
}
/// 获取字节切片引用
#[inline]
pub fn as_slice(&self) -> &[u8] {
self.0.as_ref()
}
}
impl Deref for ZeroCopyBytes {
type Target = [u8];
#[inline]
fn deref(&self) -> &Self::Target {
self.0.as_ref()
}
}
impl<'a> From<&'a str> for ZeroCopyBytes {
#[inline]
fn from(s: &'a str) -> Self {
Self(bytes::Bytes::copy_from_slice(s.as_bytes()))
}
}
impl From<String> for ZeroCopyBytes {
#[inline]
fn from(s: String) -> Self {
Self(bytes::Bytes::from(s.into_bytes()))
}
}
impl<'a> From<&'a [u8]> for ZeroCopyBytes {
#[inline]
fn from(bytes: &'a [u8]) -> Self {
Self(bytes::Bytes::copy_from_slice(bytes))
}
}
impl From<Vec<u8>> for ZeroCopyBytes {
#[inline]
fn from(bytes: Vec<u8>) -> Self {
Self(bytes::Bytes::from(bytes))
}
}
impl AsRef<[u8]> for ZeroCopyBytes {
#[inline]
fn as_ref(&self) -> &[u8] {
self.0.as_ref()
}
}

View File

@ -0,0 +1,44 @@
use crate::window::create_float_list_window::create_float_list_window_impl;
use crate::window::create_main_window::create_main_window_impl;
use crate::window::create_meeting_window::create_meeting_window_impl;
use crate::window::create_popup_window::create_popup_window_impl;
use crate::window::derive::TargetLocation;
use tauri::{AppHandle, Window};
/// 创建悬浮列表窗口
#[tauri::command]
pub async fn create_float_list_window(app: AppHandle, page_type: &str) -> Result<String, String> {
create_float_list_window_impl(std::sync::Arc::new(app), page_type).await.map(|_| "success".to_string())
}
/// 创建弹出框窗口
#[tauri::command]
pub async fn create_popup_window(app: AppHandle, window: Window) -> Result<String, String> {
let current_position_physical = window.outer_position().map_err(|e| format!("获取窗口位置失败: {}", e))?;
let current_size_physical = window.outer_size().map_err(|e| format!("获取窗口大小失败: {}", e))?;
// 获取缩放因子
let scale_factor = window.scale_factor().map_err(|e| format!("获取缩放因子失败: {}", e))?;
// 转换为逻辑位置和大小
let current_position = current_position_physical.to_logical::<i32>(scale_factor);
let current_size = current_size_physical.to_logical::<i32>(scale_factor);
let target = TargetLocation {
x: current_position.x,
y: current_position.y,
width: current_size.width as u32,
height: current_size.height as u32,
};
create_popup_window_impl(&app, &target, "auto").await.map(|_| "success".to_string())
}
#[tauri::command]
pub async fn create_main_window(app: AppHandle) -> Result<String, String> {
create_main_window_impl(&app).await.map(|_| "success".to_string())
}
/// 创建会议窗口Tauri命令
#[tauri::command]
pub async fn create_meeting_window(app: AppHandle, window_type: u8, course_id: Option<u64>) -> Result<String, String> {
create_meeting_window_impl(&app, window_type, course_id).await.map(|_| "success".to_string())
}

View File

@ -0,0 +1,241 @@
use std::sync::Arc;
use crate::{
constants::{common::*, window::*},
storage::SessionStorage,
window::{
WindowLabel,
derive::FloatListInfo,
util::{get_monitor_for_window, show_windows_by_label},
},
};
use tauri::{AppHandle, LogicalPosition, LogicalSize, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, utils::config::BackgroundThrottlingPolicy};
/// 创建悬浮列表窗口(内部实现)
pub async fn create_float_list_window_impl<R: Runtime>(app: Arc<AppHandle<R>>, page_type: &str) -> Result<(), String> {
let app_handle = Arc::clone(&app);
let page_type_owned = page_type.to_string();
if show_windows_by_label(app.as_ref(), WindowLabel::FLOAT_LIST) {
return Ok(());
}
// 悬浮列表尺寸
let float_info = calculate_float_info_sync(app.as_ref(), page_type);
// 获取屏幕尺寸优先为main窗口所在屏幕主窗口没有打开时才找主屏幕
let monitor = get_monitor_for_window(app.as_ref())?;
// 获取屏幕缩放因子
let scale_factor = monitor.scale_factor();
let page_size: LogicalSize<f64> = monitor.work_area().size.to_logical(scale_factor);
let screen_width = page_size.width;
let screen_height = page_size.height;
let url = format!(
"floating-list-window.html?isShowInteraction={}&isShowTutorship={}",
float_info.is_show_interaction, float_info.is_show_tutorship
);
let label = WindowLabel::FLOAT_LIST.as_ref();
// 创建悬浮球窗口,直接在初始化时设置好位置和尺寸
let window = WebviewWindowBuilder::new(app.as_ref(), label, WebviewUrl::App(url.into()))
.title("悬浮列表")
.inner_size(float_info.width as f64, float_info.height as f64)
.max_inner_size(float_info.width as f64, float_info.height as f64)
.min_inner_size(float_info.width as f64, float_info.height as f64)
.position(screen_width - float_info.width as f64, 0.0)
.transparent(true)
.visible_on_all_workspaces(true)
.background_throttling(BackgroundThrottlingPolicy::Disabled)
.decorations(false)
.shadow(false)
.focusable(true)
.focused(false)
.accept_first_mouse(true)
.skip_taskbar(true)
.always_on_top(true)
.devtools(true)
.build();
let window = match window {
Ok(w) => w,
Err(e) => {
log::error!("{} {}", MSG_FAILED_CREATE_FLOATING_LIST, e);
return Err(format!("{} {}", MSG_FAILED_CREATE_FLOATING_LIST, e));
}
};
#[cfg(not(target_os = "linux"))]
{
let _ = window.set_resizable(false);
}
#[cfg(target_os = "windows")]
{
let _ = window.set_size(LogicalSize::new(float_info.width as f64, float_info.height as f64));
}
// 窗口显示后等待200毫秒再执行动画
tokio::spawn(async move {
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
let _ = animate_to_bottom_right(&app_handle, window, screen_width as i32, screen_height as i32, &page_type_owned).await;
});
Ok(())
}
/// 根据用户登录状态和课程选择情况动态计算弹出窗口高度
pub fn calculate_float_info_sync<R: Runtime>(app: &AppHandle<R>, page_type: &str) -> FloatListInfo {
// 默认基础项目数
let mut item_num = 4;
let mut is_show_interaction = false;
let mut is_show_tutorship = false;
// 获取会话存储实例
let storage = match app.try_state::<SessionStorage>() {
Some(s) => s,
None => {
log::error!("获取会话存储实例失败,使用默认值");
let item_height = 64;
let popup_height = item_height * item_num + 40;
let popup_width = 60;
return FloatListInfo {
width: popup_width as u32,
height: popup_height as u32,
is_show_interaction,
is_show_tutorship,
};
}
};
// 从会话存储中获取用户信息和课程信息来判断是否增加项目数
let user_info_exists = match storage.get::<serde_json::Value>(STORAGE_KEY_USER_INFO) {
Some(Ok(value)) => {
// 检查必要的字段是否存在
value.get("id").is_some()
}
_ => false,
};
let tutorship_info_exists = match storage.get::<serde_json::Value>(STORAGE_KEY_TUTORSHIP) {
Some(Ok(value)) => {
// 检查必要的字段是否存在
value.get("id").is_some()
}
_ => false,
};
let course_info_exists = match storage.get::<serde_json::Value>(STORAGE_KEY_COURSE) {
Some(Ok(value)) => {
// 检查必要的字段是否存在
value.get("id").is_some()
}
_ => false,
};
if user_info_exists {
if page_type == "interaction" {
is_show_interaction = true;
// 如果能获取到用户信息和课程信息,则增加项目数
if course_info_exists {
item_num += 5;
}
} else if page_type == "tutorship" {
// 如果能获取到用户信息和课程信息,则增加项目数
if course_info_exists {
is_show_interaction = true;
item_num += 5;
}
if tutorship_info_exists {
is_show_tutorship = true;
item_num += 3;
}
}
}
// 每个item高度
let item_height = 64;
// 总高度 = item高度 * 项目数 + 额外用于移动按钮和时间部分的高度
let popup_height = item_height * item_num + 60;
let popup_width = 60;
// 返回计算得到的弹出窗口尺寸
FloatListInfo {
width: popup_width as u32,
height: popup_height as u32,
is_show_tutorship,
is_show_interaction,
}
}
/// 使用抛物线动画将窗口移动到右下角
pub async fn animate_to_bottom_right<R: Runtime>(
app: &AppHandle<R>,
window: tauri::WebviewWindow<R>,
screen_width: i32,
screen_height: i32,
page_type: &str,
) -> Result<(), String> {
// 获取当前窗口位置和大小
let current_position_physical = window.outer_position().map_err(|e| format!("获取窗口位置失败: {}", e))?;
let current_size_physical = window.outer_size().map_err(|e| format!("获取窗口大小失败: {}", e))?;
let scale_factor = window.scale_factor().map_err(|e| format!("获取缩放因子失败: {}", e))?;
// 转换为逻辑位置和大小
let current_position = current_position_physical.to_logical::<i32>(scale_factor);
let current_size = current_size_physical.to_logical::<i32>(scale_factor);
let start_x = current_position.x;
let start_y = current_position.y;
// 计算目标位置(右下角)
// 注意screen_width 和 screen_height 已经在调用方考虑了缩放因子
let end_x = screen_width - current_size.width;
let end_y = screen_height - current_size.height - 120;
// 计算移动距离
let distance_x = (end_x - start_x) as f64;
let distance_y = (end_y - start_y) as f64;
// 动画参数
let duration = 800; // 总动画时间(毫秒)
let start_time = std::time::Instant::now();
// 抛物线控制参数
// 抛物线顶点高度偏移量(负值表示向下偏移,形成开口向上的抛物线)
let apex_offset = -(distance_y.abs() * 0.3).min(200.0);
// 执行动画
loop {
let elapsed = start_time.elapsed().as_millis() as u64;
if elapsed >= duration {
break;
}
let progress = (elapsed as f64) / (duration as f64);
// 使用抛物线轨迹计算位置
// 水平方向匀速运动
let current_x = (start_x as f64 + distance_x * progress).round() as i32;
// 垂直方向抛物线运动(开口向上,波峰向下)
// y = apex_offset * (4*x^2 - 4*x) 形成开口向上的抛物线,谷点在 x=0.5 处
let parabolic_factor = apex_offset * (4.0 * progress * progress - 4.0 * progress);
let current_y = (start_y as f64 + distance_y * progress + parabolic_factor).round() as i32;
// 更新窗口位置,使用 LogicalPosition
if let Err(_e) = window.set_position(LogicalPosition::new(current_x as f64, current_y as f64)) {
break;
}
// 短暂休眠以控制动画帧率
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
}
// 确保最终位置准确, 再次获取窗口大小和缩放因子
let float_info = calculate_float_info_sync(app, page_type);
let current_size_physical = window.outer_size().map_err(|e| format!("获取窗口大小失败: {}", e))?;
let scale_factor = window.scale_factor().map_err(|e| format!("获取缩放因子失败: {}", e))?;
let current_size = current_size_physical.to_logical::<i32>(scale_factor);
let _ = window.set_size(LogicalSize::new(float_info.width as f64, float_info.height as f64));
let end_x = screen_width - current_size.width;
let end_y = screen_height - current_size.height - 120;
if let Err(e) = window.set_position(LogicalPosition::new(end_x as f64, end_y as f64)) {
return Err(format!("设置最终窗口位置失败: {}", e));
}
Ok(())
}

View File

@ -0,0 +1,40 @@
use crate::constants::{common::APP_NAME, window::*};
use crate::window::{util::show_windows_by_label, WindowLabel};
use tauri::{AppHandle, Runtime, WebviewUrl, WebviewWindowBuilder};
/// 根据tauri.conf.json配置创建主窗口
pub async fn create_main_window_impl<R: Runtime>(app: &AppHandle<R>) -> Result<(), String> {
// 检查是否已经存在主窗口
if show_windows_by_label(app, WindowLabel::MAIN) {
return Ok(());
}
let label = WindowLabel::MAIN.as_ref();
// 根据tauri.conf.json配置创建主窗口
let _window = WebviewWindowBuilder::new(app, label, WebviewUrl::App("index.html".into()))
.title(APP_NAME)
.inner_size(MAIN_WINDOW_WIDTH, MAIN_WINDOW_HEIGHT)
.resizable(true)
.fullscreen(false)
.decorations(false)
.transparent(true)
.always_on_top(false)
.center()
.shadow(true)
.devtools(true)
.zoom_hotkeys_enabled(true)
.focusable(true)
.accept_first_mouse(true)
.visible(true)
.build()
.map_err(|e| format!("创建主窗口失败: {}", e))?;
// 设置窗口圆角效果
#[cfg(any(target_os = "windows", target_os = "macos"))]
{
use tauri::window::{Color, EffectsBuilder};
let _ = _window.set_effects(Some(EffectsBuilder::new().radius(WINDOW_CORNER_RADIUS).color(Color(0, 0, 0, 255)).build()));
}
Ok(())
}

View File

@ -0,0 +1,59 @@
use crate::constants::{common::*, window::*};
use crate::env::TEACHERCONTROL_JK_URL;
use crate::window::WindowLabel;
use tauri::{AppHandle, Manager, Runtime, WebviewUrl, WebviewWindowBuilder};
/// 创建会议窗口(内部实现)
pub async fn create_meeting_window_impl<R: Runtime>(app: &AppHandle<R>, window_type: u8, course_id: Option<u64>) -> Result<(), String> {
// 根据窗口类型定义label和url
let (label, url, title, width, height, decorations, transparent) = match window_type {
1 => {
let label = WindowLabel::MEETING.as_ref();
(
label,
WebviewUrl::App("/#/meeting".into()),
"会议",
MEETING_TYPE_1_WIDTH,
MEETING_TYPE_1_HEIGHT,
false,
true,
)
}
2 => {
if let Some(id) = course_id {
println!("创建会议窗口2: {}", TEACHERCONTROL_JK_URL);
let url = format!("{}?CourseID={}", TEACHERCONTROL_JK_URL, id);
let label = WindowLabel::MEETING2.as_ref();
(
label,
WebviewUrl::External(url.parse().map_err(|_| "URL格式错误".to_string())?),
"会议2",
MEETING_TYPE_2_WIDTH,
MEETING_TYPE_2_HEIGHT,
true,
false,
)
} else {
return Err(MSG_MEETING_TYPE_2_NEEDS_COURSE_ID.to_string());
}
}
_ => return Err(MSG_UNSUPPORTED_MEETING_WINDOW_TYPE.to_string()),
};
// 如果窗口已存在,先关闭它
if let Some(win) = app.get_webview_window(label) {
let _ = win.close();
}
// 创建窗口
WebviewWindowBuilder::new(app, label, url)
.title(title)
.inner_size(width, height)
.decorations(decorations)
.transparent(transparent)
.devtools(true)
.build()
.map_err(|e| format!("{} {}", MSG_FAILED_CREATE_MEETING_WINDOW, e))?;
Ok(())
}

View File

@ -0,0 +1,218 @@
use crate::constants::window::*;
use crate::window::{
derive::{PopupLocation, PopupSize, TargetLocation},
util::get_monitor_for_window,
WindowLabel,
};
use tauri::{AppHandle, Manager, Runtime, WebviewUrl, WebviewWindowBuilder};
/// 创建弹出框窗口(内部实现)
pub async fn create_popup_window_impl<R: Runtime>(app: &AppHandle<R>, target: &TargetLocation, placement: &str) -> Result<(), String> {
// 获取屏幕尺寸优先为main窗口所在屏幕主窗口没有打开时才找主屏幕
let monitor: tauri::Monitor = get_monitor_for_window(app)?;
// 使用动态计算的弹出窗口尺寸
let popup_size = calculate_popup_size_sync();
let page_size = monitor.size().to_logical(monitor.scale_factor());
// 计算弹出框位置和大小
let popup_location = calculate_placement(target, &popup_size, placement, &page_size).await?;
let label = WindowLabel::POPUP.as_ref();
// 检查是否已经存在名为"popup"的窗口
if let Some(popup_window) = app.get_webview_window(label) {
// 如果窗口已存在,直接关闭它
let _ = popup_window.close();
}
let url = format!("popup-window.html?placement={}", popup_location.placement); // 创建弹出框窗口
WebviewWindowBuilder::new(app, label, WebviewUrl::App(url.into()))
.title("弹出框")
.inner_size(popup_location.width as f64, popup_location.height as f64)
.position(popup_location.x as f64, popup_location.y as f64)
.resizable(false)
.transparent(true)
.decorations(false)
.shadow(false)
.focusable(true)
.accept_first_mouse(true)
.skip_taskbar(true)
.always_on_top(true)
.visible(true)
.build()
.map_err(|e| format!("创建弹出框窗口失败: {}", e))?;
Ok(())
}
/// 根据用户登录状态和课程选择情况动态计算弹出窗口高度
pub fn calculate_popup_size_sync() -> PopupSize {
// 默认基础项目数
let item_num = 3;
// 根据项目数计算高度每个项目40像素加上边距2像素
let popup_height = 40 * item_num + 2;
let popup_width = 180;
// 返回计算得到的弹出窗口尺寸
PopupSize {
width: popup_width,
height: popup_height,
}
}
/// 计算弹出框位置
pub async fn calculate_placement(
target: &TargetLocation,
popup_size: &PopupSize,
placement: &str,
page_size: &tauri::LogicalSize<i32>,
) -> Result<PopupLocation, String> {
let margin = 12; // 箭头距离触发元素的距离
let mar = 12; // 距离屏幕边缘的最小距离
let page_width = page_size.width;
let page_height = page_size.height;
// 点击dom宽高
let target_width = target.width;
let target_height = target.height;
// 点击dom右上角的坐标
let target_x = target.x;
let target_y = target.y;
// 点击dom中心点坐标
let center_x = target_x + (target_width / 2) as i32;
let center_y = target_y + (target_height / 2) as i32;
// 弹出框的宽高
let bubble_width = popup_size.width;
let bubble_height = popup_size.height;
// 如果placement为"center",则直接返回屏幕中央位置
if placement == PLACEMENT_CENTER {
return Ok(calculate_center_position(popup_size, page_size));
}
// 上面足够
let top_enough = bubble_height as i32 + margin + mar < target_y;
// 下面足够
let bottom_enough = (target_y + target_height as i32 + bubble_height as i32 + margin + mar) < page_height;
// 左面足够
let left_enough = target_x - bubble_width as i32 - margin - mar >= 0;
// 右面足够
let right_enough = target_x + target_width as i32 + bubble_width as i32 + margin + mar < page_width;
// 屏幕宽度足够
let width_enough = page_width > bubble_width as i32 + mar * 2;
// 屏幕高度足够
let height_enough = page_height > bubble_height as i32 + mar * 2;
if (placement == PLACEMENT_AUTO || placement == PLACEMENT_TOP) && top_enough && width_enough {
get_top_bottom_placement(PLACEMENT_TOP, target, popup_size, margin, mar, center_x, page_width)
} else if (placement == PLACEMENT_AUTO || placement == PLACEMENT_BOTTOM) && bottom_enough && width_enough {
get_top_bottom_placement(PLACEMENT_BOTTOM, target, popup_size, margin, mar, center_x, page_width)
} else if (placement == PLACEMENT_AUTO || placement == PLACEMENT_LEFT) && left_enough && height_enough {
get_left_right_placement(PLACEMENT_LEFT, target, popup_size, margin, mar, center_y, page_height)
} else if (placement == PLACEMENT_AUTO || placement == PLACEMENT_RIGHT) && right_enough && height_enough {
get_left_right_placement(PLACEMENT_RIGHT, target, popup_size, margin, mar, center_y, page_height)
} else {
let popup_y = center_y + (target_height as i32) / 2 + margin;
Ok(PopupLocation {
enough_show: false,
placement: PLACEMENT_CENTER.to_string(),
x: 0,
y: popup_y,
width: popup_size.width,
height: popup_size.height,
})
}
}
/// 计算屏幕中央位置
pub fn calculate_center_position(popup_size: &PopupSize, page_size: &tauri::LogicalSize<i32>) -> PopupLocation {
let page_width = page_size.width;
let page_height = page_size.height;
let popup_x = (page_width - popup_size.width as i32) / 2;
let popup_y = (page_height - popup_size.height as i32) / 2;
PopupLocation {
enough_show: true,
placement: PLACEMENT_CENTER.to_string(),
x: popup_x,
y: popup_y,
width: popup_size.width,
height: popup_size.height,
}
}
/// 上或者下足够并且宽度也足够时的位置计算
fn get_top_bottom_placement(
placement: &str,
target: &TargetLocation,
popup_size: &PopupSize,
margin: i32,
mar: i32,
center_x: i32,
page_width: i32,
) -> Result<PopupLocation, String> {
let popup_y = if placement == "bottom" {
target.y + target.height as i32 + margin
} else {
target.y - popup_size.height as i32 - margin
};
let right_x = center_x - (popup_size.width as i32) / 2;
let left_x = center_x + (popup_size.width as i32) / 2;
let mut popup_x = mar;
if right_x >= mar && page_width - left_x >= mar {
popup_x = right_x;
} else if page_width - left_x >= mar {
popup_x = mar;
} else if right_x >= mar {
popup_x = page_width - popup_size.width as i32 - mar;
}
Ok(PopupLocation {
enough_show: true,
placement: placement.to_string(),
y: popup_y,
x: popup_x,
width: popup_size.width,
height: popup_size.height,
})
}
/// 左或者右足够并且高度也足够时的位置计算
fn get_left_right_placement(
placement: &str,
target: &TargetLocation,
popup_size: &PopupSize,
margin: i32,
mar: i32,
center_y: i32,
page_height: i32,
) -> Result<PopupLocation, String> {
let popup_x = if placement == "left" {
target.x - popup_size.width as i32 - margin
} else {
target.x + target.width as i32 + margin
};
let top_y = center_y - (popup_size.height as i32) / 2;
let bottom_y = center_y + (popup_size.height as i32) / 2;
let popup_y = if top_y >= mar && page_height - bottom_y >= mar {
// 水平居中
top_y
} else if page_height - bottom_y >= mar {
mar
} else {
page_height - popup_size.height as i32 - mar
};
Ok(PopupLocation {
enough_show: true,
placement: placement.to_string(),
y: popup_y,
x: popup_x,
width: popup_size.width,
height: popup_size.height,
})
}

View File

@ -0,0 +1,53 @@
use serde::{Deserialize, Serialize};
/// 目标位置信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TargetLocation {
/// 触发元素右上角的X轴坐标
pub x: i32,
/// 触发元素右上角的Y轴坐标
pub y: i32,
/// 触发元素宽高
pub width: u32,
/// 触发元素宽高
pub height: u32,
}
/// 弹出框尺寸信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PopupSize {
/// 弹出框的宽度
pub width: u32,
/// 弹出框的高度
pub height: u32,
}
/// 悬浮列表信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FloatListInfo {
/// 弹出框的宽度
pub width: u32,
/// 弹出框的高度
pub height: u32,
/** 是否显示互动课堂相关按钮 */
pub is_show_interaction: bool,
/** 是否显示智评会议相关按钮 */
pub is_show_tutorship: bool,
}
/// 弹出框位置信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PopupLocation {
/// 弹出bubble的位置
pub placement: String,
/// 是浏览器窗口否足够bubble显示, true:足够显示; false:不足够(改用ElDialog方式显示)
pub enough_show: bool,
/// bubble弹出框右上角的Y轴坐标
pub y: i32,
/// bubble弹出框右上角的X轴坐标
pub x: i32,
/// 弹出框的宽度
pub width: u32,
/// 弹出框的高度
pub height: u32,
}

View File

@ -0,0 +1,60 @@
//! 窗口标签定义模块
//!
//! 提供窗口标签的类型安全常量,避免硬编码字符串。
use std::fmt::{Display, Formatter};
/// 窗口标签类型
///
/// 提供类型安全的窗口标签常量,避免使用魔法字符串
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct WindowLabel(&'static str);
impl WindowLabel {
/// 主窗口
pub const MAIN: Self = Self("main");
/// 悬浮列表窗口
pub const FLOAT_LIST: Self = Self("float-list");
/// 弹出框窗口
pub const POPUP: Self = Self("popup");
/// 画板窗口
pub const DRAWING_BOARD: Self = Self("drawing-board");
/// 实时字幕窗口
pub const SUBTITLE: Self = Self("subtitle");
/// 会议窗口
pub const MEETING: Self = Self("meeting");
/// 会议窗口2
pub const MEETING2: Self = Self("meeting2");
/// 系统托盘
pub const MAIN_TRAY: Self = Self("main");
}
impl Display for WindowLabel {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl AsRef<str> for WindowLabel {
fn as_ref(&self) -> &str {
self.0
}
}
impl From<WindowLabel> for String {
fn from(label: WindowLabel) -> Self {
label.0.to_string()
}
}
impl From<WindowLabel> for &'static str {
fn from(label: WindowLabel) -> Self {
label.0
}
}
impl<'a> From<&'a WindowLabel> for &'a str {
fn from(label: &'a WindowLabel) -> Self {
label.0
}
}

View File

@ -0,0 +1,10 @@
pub mod commands;
pub mod create_float_list_window;
pub mod create_main_window;
pub mod create_meeting_window;
pub mod create_popup_window;
pub mod derive;
pub mod label;
pub mod util;
pub use label::WindowLabel;

View File

@ -0,0 +1,76 @@
//! 窗口工具模块
//!
//! 提供窗口相关的实用函数,包括动画和位置计算等功能。
use super::WindowLabel;
use tauri::{AppHandle, Manager, Runtime};
/**
* 显示指定标签的窗口,如果窗口已存在则将其打开。
*/
pub fn show_windows_by_label<R: Runtime>(app: &AppHandle<R>, window_label: WindowLabel) -> bool {
// 检查是否已存,如果存在则直接打开
let label = window_label.as_ref();
let window = app.get_webview_window(label);
if let Some(existing_window) = window {
if let Ok(visible) = existing_window.is_visible()
&& !visible
{
let _ = existing_window.show();
}
if let Ok(minimized) = existing_window.is_minimized()
&& minimized
{
let _ = existing_window.unminimize();
}
if let Ok(focused) = existing_window.is_focused()
&& !focused
{
let _ = existing_window.set_focus();
}
return true;
}
false
}
/// 检查窗口是否可见且未最小化
#[allow(dead_code)]
pub fn check_window_visible<R: Runtime>(app: &AppHandle<R>, window_label: WindowLabel) -> bool {
let label = window_label.as_ref();
if let Some(window) = app.get_webview_window(label) {
// 检查窗口是否可见
let is_visible = window.is_visible().unwrap_or(false);
// 如果窗口不可见直接返回false
if !is_visible {
return false;
}
// 检查窗口是否最小化
let is_minimized = window.is_minimized().unwrap_or(false);
// 窗口可见且未最小化才认为是真正可见
is_visible && !is_minimized
} else {
false
}
}
/// 获取显示器优先使用main窗口所在显示器回退到主显示器
pub fn get_monitor_for_window<R: Runtime>(app: &AppHandle<R>) -> Result<tauri::Monitor, String> {
if let Some(main_window) = app.get_webview_window(WindowLabel::MAIN.as_ref()) {
// 尝试获取主窗口所在的显示器
let Ok(Some(monitor)) = main_window.current_monitor() else {
return Err("未找到主窗口显示器".to_string());
};
Ok(monitor)
} else {
// 主窗口未打开,获取主显示器
let Ok(Some(monitor)) = app.primary_monitor() else {
return Err("未找到主显示器".to_string());
};
Ok(monitor)
}
}