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:
27
src-tauri/src/app/commands.rs
Normal file
27
src-tauri/src/app/commands.rs
Normal 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();
|
||||
}
|
||||
18
src-tauri/src/app/initializer.rs
Normal file
18
src-tauri/src/app/initializer.rs
Normal 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
2
src-tauri/src/app/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod commands;
|
||||
pub mod initializer;
|
||||
15
src-tauri/src/audio/commands.rs
Normal file
15
src-tauri/src/audio/commands.rs
Normal 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
138
src-tauri/src/audio/mod.rs
Normal 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();
|
||||
}
|
||||
55
src-tauri/src/constants/common.rs
Normal file
55
src-tauri/src/constants/common.rs
Normal 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 = "不支持的会议窗口类型";
|
||||
3
src-tauri/src/constants/mod.rs
Normal file
3
src-tauri/src/constants/mod.rs
Normal file
@ -0,0 +1,3 @@
|
||||
// 常量模块
|
||||
pub mod common;
|
||||
pub mod window;
|
||||
56
src-tauri/src/constants/window.rs
Normal file
56
src-tauri/src/constants/window.rs
Normal 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
11
src-tauri/src/env/mod.rs
vendored
Normal 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
227
src-tauri/src/error/mod.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
46
src-tauri/src/invoke/handlers.rs
Normal file
46
src-tauri/src/invoke/handlers.rs
Normal 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,
|
||||
])
|
||||
}
|
||||
1
src-tauri/src/invoke/mod.rs
Normal file
1
src-tauri/src/invoke/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod handlers;
|
||||
144
src-tauri/src/lib.rs
Normal file
144
src-tauri/src/lib.rs
Normal 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
6
src-tauri/src/main.rs
Normal 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();
|
||||
}
|
||||
47
src-tauri/src/menu/app_menu.rs
Normal file
47
src-tauri/src/menu/app_menu.rs
Normal 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)
|
||||
}
|
||||
259
src-tauri/src/menu/menu_event.rs
Normal file
259
src-tauri/src/menu/menu_event.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
19
src-tauri/src/menu/menu_handler.rs
Normal file
19
src-tauri/src/menu/menu_handler.rs
Normal 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),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
6
src-tauri/src/menu/mod.rs
Normal file
6
src-tauri/src/menu/mod.rs
Normal 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;
|
||||
4
src-tauri/src/storage/mod.rs
Normal file
4
src-tauri/src/storage/mod.rs
Normal file
@ -0,0 +1,4 @@
|
||||
pub mod session_storage;
|
||||
|
||||
// 重新导出存储结构和命令
|
||||
pub use session_storage::SessionStorage;
|
||||
73
src-tauri/src/storage/session_storage/commands.rs
Normal file
73
src-tauri/src/storage/session_storage/commands.rs
Normal 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)
|
||||
}
|
||||
4
src-tauri/src/storage/session_storage/mod.rs
Normal file
4
src-tauri/src/storage/session_storage/mod.rs
Normal file
@ -0,0 +1,4 @@
|
||||
mod storage;
|
||||
pub use storage::SessionStorage;
|
||||
mod commands;
|
||||
pub use commands::*;
|
||||
123
src-tauri/src/storage/session_storage/storage.rs
Normal file
123
src-tauri/src/storage/session_storage/storage.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
1
src-tauri/src/task/mod.rs
Normal file
1
src-tauri/src/task/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod scheduled_restart;
|
||||
51
src-tauri/src/task/scheduled_restart.rs
Normal file
51
src-tauri/src/task/scheduled_restart.rs
Normal 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(())
|
||||
}
|
||||
18
src-tauri/src/tray/commands.rs
Normal file
18
src-tauri/src/tray/commands.rs
Normal 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(())
|
||||
}
|
||||
83
src-tauri/src/tray/common_tray.rs
Normal file
83
src-tauri/src/tray/common_tray.rs
Normal 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 }
|
||||
}
|
||||
134
src-tauri/src/tray/linux_tray.rs
Normal file
134
src-tauri/src/tray/linux_tray.rs
Normal 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
13
src-tauri/src/tray/mod.rs
Normal 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;
|
||||
5
src-tauri/src/utils/mod.rs
Normal file
5
src-tauri/src/utils/mod.rs
Normal file
@ -0,0 +1,5 @@
|
||||
//! 工具模块
|
||||
//!
|
||||
//! 提供各种工具函数和零拷贝优化
|
||||
|
||||
pub mod zero_copy;
|
||||
69
src-tauri/src/utils/zero_copy/mod.rs
Normal file
69
src-tauri/src/utils/zero_copy/mod.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
44
src-tauri/src/window/commands.rs
Normal file
44
src-tauri/src/window/commands.rs
Normal 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())
|
||||
}
|
||||
241
src-tauri/src/window/create_float_list_window.rs
Normal file
241
src-tauri/src/window/create_float_list_window.rs
Normal 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(())
|
||||
}
|
||||
40
src-tauri/src/window/create_main_window.rs
Normal file
40
src-tauri/src/window/create_main_window.rs
Normal 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(())
|
||||
}
|
||||
59
src-tauri/src/window/create_meeting_window.rs
Normal file
59
src-tauri/src/window/create_meeting_window.rs
Normal 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(())
|
||||
}
|
||||
218
src-tauri/src/window/create_popup_window.rs
Normal file
218
src-tauri/src/window/create_popup_window.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
53
src-tauri/src/window/derive.rs
Normal file
53
src-tauri/src/window/derive.rs
Normal 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,
|
||||
}
|
||||
60
src-tauri/src/window/label.rs
Normal file
60
src-tauri/src/window/label.rs
Normal 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
|
||||
}
|
||||
}
|
||||
10
src-tauri/src/window/mod.rs
Normal file
10
src-tauri/src/window/mod.rs
Normal 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;
|
||||
76
src-tauri/src/window/util.rs
Normal file
76
src-tauri/src/window/util.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user