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

4
src-tauri/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas

7102
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

114
src-tauri/Cargo.toml Normal file
View File

@ -0,0 +1,114 @@
[package]
name = "zhipinghuiyi"
version = "0.1.0"
description = "树人教育研究院旗下智评会议APP"
authors = ["mxp131011@qq.com"]
license = ""
repository = ""
edition = "2024"
rust-version = "1.94.0"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "zhipinghuiyi_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.5.5", features = [] }
[features]
default = ["production"]
development = []
production = []
prod_150_8080 = []
[dependencies]
serde = { version = "1.0.228", features = ["derive"] }
serde_json = { version = "1.0.149", features = ["raw_value"] }
tauri-plugin-persisted-scope = "2.3.5"
log = "0.4.29"
tauri = { version = "2.10.2", features = [
"macos-private-api",
"tray-icon",
"image-ico",
"image-png",
"devtools",
] }
tauri-plugin-os = "2.3.2"
tauri-plugin-dialog = "2.6.0"
tauri-plugin-fs = "2.4.5"
tauri-plugin-http = "2.5.7"
tauri-plugin-process = "2.3.1"
tauri-plugin-websocket = "2.4.2"
rodio = "0.22.1"
lazy_static = "1.5.0"
tokio = { version = "1.49.0", features = ["full"] }
parking_lot = "0.12.5"
once_cell = "1.21.3"
tokio-cron-scheduler = "0.15.1"
tracing = "0.1.44"
thiserror = "2.0.18"
bytes = "1.11.1"
# Linux 平台依赖:获取工作区尺寸
# [target.'cfg(target_os = "linux")'.dependencies]
# x11-dl = "2.21"
# macOS 平台依赖
# [target.'cfg(target_os = "macos")'.dependencies]
# objc2 = "0.6"
# objc2-foundation = "0.3"
# objc2-app-kit = "0.3"
# Windows 平台依赖
# [target.'cfg(target_os = "windows")'.dependencies]
# windows = { version = "0.58", features = ["Win32_UI_WindowsAndMessaging", "Win32_Foundation"] }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-updater = "2.10.0"
tauri-plugin-single-instance = "2.4.0"
# ==================== 开发环境配置 ====================
# 优化策略:保持 opt-level = 3 的同时加快编译速度
[profile.dev]
opt-level = 3 # 必须保持为 3
debug = 1 # 保留最小调试信息line tables only加速链接
strip = "none" # 开发时不剥离任何信息
debug-assertions = true # 开启调试断言帮助发现bug
overflow-checks = true # 开启溢出检查
lto = false # 关闭 LTO大幅加快编译
codegen-units = 256 # 增加并行编译单元,加快编译(牺牲一些运行性能)
panic = 'unwind' # 使用 unwind 以便调试
incremental = true # 启用增量编译,大幅加快重复编译
rpath = false
# 构建依赖优化proc-macro, build-script
[profile.dev.build-override]
opt-level = 0 # 构建脚本不需要优化,加快编译
debug = 0 # 构建脚本不需要调试信息
codegen-units = 256 # 最大化并行
incremental = true
# 为特定慢编译的 crate 启用优化
# 这些 crate 编译很慢,启用优化可以显著加速编译
[profile.dev.package."*"]
opt-level = 3 # 依赖库全优化
# ==================== 生产环境配置 ====================
# 性能优先 + 安全优先
[profile.release]
opt-level = 3 # 最高优化级别(性能优先)
debug = false # 不包含调试信息
strip = "symbols" # 剥离所有符号(包括调试符号和函数名),减小体积,提升安全性
debug-assertions = false # 关闭调试断言
overflow-checks = false # 关闭溢出检查(性能优先)
lto = "fat" # 完整 LTO链接时优化最佳性能
codegen-units = 1 # 单个代码生成单元,最佳优化
panic = 'abort' # panic 时直接中止,减小体积和提升性能
rpath = false # 不使用 rpath安全考虑
incremental = false # 禁用增量编译

View File

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- 麦克风权限 -->
<key>com.apple.security.device.microphone</key>
<true/>
<!-- 音频输入权限 -->
<key>com.apple.security.device.audio-input</key>
<true/>
<!-- 相机权限 -->
<key>com.apple.security.device.camera</key>
<true/>
<!-- 文件读写权限 -->
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<!-- 网络客户端权限 -->
<key>com.apple.security.network.client</key>
<true/>
<!-- 网络服务端权限 -->
<key>com.apple.security.network.server</key>
<true/>
<!-- 蓝牙权限 -->
<key>com.apple.security.device.bluetooth</key>
<true/>
<!-- 本地通知权限 -->
<key>com.apple.security.notifications</key>
<true/>
<!-- 自动化苹果事件权限
<key>com.apple.security.automation.apple-events</key>
<true/> -->
<!-- 应用沙盒 -->
<!-- <key>com.apple.security.app-sandbox</key>
<true/> -->
</dict>
</plist>

56
src-tauri/Info.plist Normal file
View File

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- 开发区域设置为简体中文 -->
<key>CFBundleDevelopmentRegion</key>
<string>zh_CN</string>
<!-- 屏幕捕获使用描述,告知用户为何需要此权限 -->
<key>NSScreenCaptureUsageDescription</key>
<string>智评会议需要访问您的屏幕以进行屏幕共享</string>
<!-- 本地化语言支持列表 -->
<key>CFBundleLocalizations</key>
<array>
<string>zh_CN</string>
<string>en</string>
</array>
<!-- Apple语言设置 -->
<key>NSAppleLanguages</key>
<array>
<string>zh-Hans-CN</string>
<string>en</string>
</array>
<!-- 麦克风权限说明 -->
<key>NSMicrophoneUsageDescription</key>
<string>需要访问您的麦克风,用于语音输入和通话功能。</string>
<!-- 相机权限说明 -->
<key>NSCameraUsageDescription</key>
<string>需要访问您的相机,用于拍照和视频通话功能。</string>
<!-- 照片库权限说明 -->
<key>NSPhotoLibraryUsageDescription</key>
<string>需要访问您的照片库,用于上传和保存图片。</string>
<!-- 文档文件夹访问权限说明 -->
<key>NSDocumentsFolderUsageDescription</key>
<string>需要访问您的文档文件夹,用于文件的读取和保存。</string>
<!-- 蓝牙权限说明 -->
<key>NSBluetoothAlwaysUsageDescription</key>
<string>需要访问蓝牙,用于连接蓝牙设备。</string>
<!-- 本地网络权限说明 -->
<key>NSLocalNetworkUsageDescription</key>
<string>需要访问本地网络,用于设备间通信。</string>
<!-- 用户通知权限说明 -->
<key>NSUserNotificationUsageDescription</key>
<string>需要发送通知,用于消息提醒。</string>
</dict>
</plist>

3
src-tauri/build.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@ -0,0 +1,30 @@
{
"$schema": "../gen/schemas-zh/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": ["main", "meeting", "meeting2", "popup", "float-list"],
"permissions": [
"core:default",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-unmaximize",
"core:window:allow-is-maximized",
"core:window:allow-toggle-maximize",
"core:window:allow-is-fullscreen",
"core:window:allow-set-fullscreen",
"core:window:allow-start-dragging",
"core:window:allow-show",
"core:window:allow-hide",
"core:window:allow-close",
"core:window:allow-create",
"core:window:allow-set-size",
"core:window:allow-unminimize",
"core:window:allow-is-resizable",
"core:window:allow-set-position",
"core:webview:allow-create-webview",
"core:webview:allow-create-webview-window",
"core:webview:allow-set-webview-zoom",
"core:window:allow-set-always-on-top",
"core:window:allow-set-focus"
]
}

View File

@ -0,0 +1,27 @@
{
"$schema": "../gen/schemas-zh/desktop-schema.json",
"identifier": "desktop-capability",
"platforms": ["macOS", "windows", "linux"],
"windows": ["main", "meeting", "meeting2", "popup", "float-list"],
"permissions": [
"dialog:default",
"updater:default",
"process:default",
"process:allow-exit",
"fs:default",
"fs:allow-write-file",
"fs:allow-download-write",
"websocket:default",
{
"identifier": "http:default",
"allow": [
{
"url": "http://*:*"
},
{
"url": "https://*:*"
}
]
}
]
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,61 @@
{
"default": {
"identifier": "default",
"description": "enables the default permissions",
"local": true,
"windows": ["main", "meeting", "drawing-board", "popup", "float-list", "draw-board-tools"],
"permissions": [
"core:default",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-unmaximize",
"core:window:allow-is-maximized",
"core:window:allow-toggle-maximize",
"core:window:allow-is-fullscreen",
"core:window:allow-set-fullscreen",
"core:window:allow-start-dragging",
"core:window:allow-show",
"core:window:allow-hide",
"core:window:allow-close",
"core:window:allow-create",
"core:window:allow-set-size",
"core:window:allow-unminimize",
"core:window:allow-is-resizable",
"core:window:allow-set-position",
"core:webview:allow-create-webview",
"core:webview:allow-create-webview-window",
"core:webview:allow-set-webview-zoom",
"core:window:allow-set-always-on-top",
"core:window:allow-set-focus"
]
},
"desktop-capability": {
"identifier": "desktop-capability",
"description": "",
"local": true,
"windows": ["main", "meeting", "drawing-board", "popup", "float-list", "draw-board-tools"],
"permissions": [
"dialog:default",
"updater:default",
"process:default",
"window-state:default",
"process:allow-exit",
"fs:default",
"fs:allow-write-file",
"fs:allow-download-write",
{
"identifier": "http:default",
"allow": [
{
"url": "http://*:*"
},
{
"url": "https://*:*"
}
]
},
"websocket:default"
],
"platforms": ["macOS", "windows", "linux"]
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
src-tauri/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="14" fill="#795548" stroke="#5D4037" stroke-width="2"/>
<line x1="10" y1="10" x2="22" y2="22" stroke="white" stroke-width="3" stroke-linecap="round"/>
<line x1="22" y1="10" x2="10" y2="22" stroke="white" stroke-width="3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 369 B

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="14" fill="#2196F3" stroke="#1976D2" stroke-width="2"/>
<circle cx="12" cy="12" r="4" fill="#F44336"/>
<circle cx="20" cy="12" r="4" fill="#4CAF50"/>
<circle cx="16" cy="20" r="4" fill="#FFEB3B"/>
<circle cx="12" cy="16" r="2.5" fill="#9C27B0"/>
<circle cx="20" cy="16" r="2.5" fill="#00BCD4"/>
</svg>

After

Width:  |  Height:  |  Size: 424 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="14" fill="#F44336" stroke="#C62828" stroke-width="2"/>
<rect x="8" y="10" width="16" height="12" rx="2" fill="white" stroke="#333" stroke-width="1.5"/>
<rect x="10" y="22" width="12" height="3" fill="#DDD" stroke="#999" stroke-width="0.5"/>
<line x1="11" y1="13" x2="21" y2="19" stroke="#999" stroke-width="1" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 461 B

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="14" fill="#4CAF50" stroke="#2E7D32" stroke-width="2"/>
<path d="M8 24 L14 10 L18 18 L24 8 L26 12 L18 26 Z" fill="white" stroke="#333" stroke-width="1.5" stroke-linejoin="round"/>
<circle cx="10" cy="22" r="1.5" fill="#333"/>
</svg>

After

Width:  |  Height:  |  Size: 349 B

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="14" fill="#FF9800" stroke="#F57C00" stroke-width="2"/>
<rect x="8" y="12" width="6" height="6" fill="white" stroke="#333" stroke-width="1"/>
<line x1="17" y1="15" x2="23" y2="15" stroke="white" stroke-width="1.5" stroke-linecap="round"/>
<circle cx="20" cy="20" r="3" fill="none" stroke="white" stroke-width="1.5"/>
<path d="M12 22 L14 26 L18 26" fill="none" stroke="white" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 568 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="14" fill="#9C27B0" stroke="#7B1FA2" stroke-width="2"/>
<line x1="8" y1="16" x2="24" y2="16" stroke="white" stroke-width="2" stroke-linecap="round"/>
<line x1="10" y1="16" x2="22" y2="16" stroke="white" stroke-width="4" stroke-linecap="round"/>
<line x1="12" y1="16" x2="20" y2="16" stroke="white" stroke-width="6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 465 B

31
src-tauri/rustfmt.toml Normal file
View File

@ -0,0 +1,31 @@
# 最大行宽,超过此宽度将换行
max_width = 160
# 是否使用硬制表符false表示使用空格
hard_tabs = false
# 制表符的空格数
tab_spaces = 4
# 换行符风格,"Auto"表示自动检测
newline_style = "Auto"
# 启发式换行策略,"Default"表示使用默认策略
use_small_heuristics = "Default"
# 是否重新排序导入语句
reorder_imports = true
# 是否重新排序模块
reorder_modules = true
# 是否删除嵌套括号
remove_nested_parens = true
# Rust版本影响格式化规则
edition = "2024"
# 是否合并派生宏
merge_derives = true
# 是否将 Result<T, E> 转换为 ? 操作符
use_try_shorthand = true
# 是否使用字段初始化简写
use_field_init_shorthand = false
# 是否强制显式 ABI
force_explicit_abi = true
# 导入粒度,"Crate"表示按 crate 分组
imports_granularity = "Crate"
# 删除行尾的多余空格
remove_trailing_whitespace = true

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

Some files were not shown because too many files have changed in this diff Show More