tauri2编程,前端部分和electron差不多,框架部分差别大,资料少,官网乱,AI又骗我
所以在gitee上,寻找tauri v2开源项目,
通过记录框架部分与rust部分的写法,对照确定编程方式
tarui2插件,可以查看:https://github.com/tauri-apps/plugins-workspace
https://gitee.com/ayangweb/EcoPaste
剪切板管理工具
控制窗口首次显示时是否接受第一个鼠标点击事件(仅 macOS)
是否在任务栏(Windows/Linux)或 Dock(macOS)中显示窗口图标
窗口是否在所有虚拟桌面(工作区)中可见(跨工作区显示)。
true:窗口会出现在所有工作区(如全局悬浮工具栏)。
false:仅当前工作区可见(默认)。
作用:标题栏样式(仅 macOS)
窗口视觉效果(仅 macOS)。
effects:可选值如 “sidebar”(侧边栏模糊效果)、“menu”(菜单栏效果)。
state:效果状态,如 “active”(激活时显示)、“inactive”(非激活时显示)
文件路径访问是否要求以 . 开头的字面量(防止路径遍历攻击)。
false:允许无 . 的路径(如 …/ 相对路径)。
true:严格限制路径必须以 . 开头(更安全,但灵活性低)。
影响范围:
assetProtocol.scope:控制前端通过 asset: 协议加载文件的路径规则。
fs 插件:控制文件系统 API 的路径检查。
"app": {
"macOSPrivateApi": true,
"windows": [
{
"label": "main",
"title": "EcoPaste",
"url": "index.html/#/",
...
"alwaysOnTop": true,
"acceptFirstMouse": true,
"skipTaskbar": true,
"visibleOnAllWorkspaces": true
},
{
"label": "preference",
"url": "index.html/#/preference",
...
"maximizable": false,
"hiddenTitle": true,
"skipTaskbar": true,
"titleBarStyle": "Overlay",
"dragDropEnabled": false,
"windowEffects": {
"effects": ["sidebar"],
"state": "active"
}
}
],
"security": {
"csp": null,
"dangerousDisableAssetCspModification": true,
"assetProtocol": {
"enable": true,
"scope": {
"allow": ["**/*"],
"requireLiteralLeadingDot": false
}
}
}
},
...
"plugins": {
"updater": {
"pubkey": "***",
"endpoints": ["https://api.ecopaste.cn/update"]
},
"fs": {
"requireLiteralLeadingDot": false
}
}
安装的插件
[dependencies]
tauri = { workspace = true, features = ["tray-icon", "protocol-asset", "macos-private-api", "image-ico"] }
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
tauri-plugin-shell.workspace = true
tauri-plugin-single-instance = "2"
tauri-plugin-autostart = "2"
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
tauri-plugin-log = "2"
tauri-plugin-global-shortcut = "2"
tauri-plugin-os = "2"
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
tauri-plugin-drag = "2"
tauri-plugin-macos-permissions = "2"
tauri-plugin-locale = "2"
tauri-plugin-opener = "2"
tauri-plugin-prevent-default = "1"
tauri-plugin-fs-pro.workspace = true
tauri-plugin-eco-window.workspace = true
tauri-plugin-eco-clipboard.workspace = true
tauri-plugin-eco-ocr.workspace = true
tauri-plugin-eco-paste.workspace = true
tauri-plugin-eco-autostart.workspace = true
获得本地窗口
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
....
// 监听显示窗口的事件
useTauriListen(LISTEN_KEY.SHOW_WINDOW, ({ payload }) => {
const appWindow = getCurrentWebviewWindow();
if (appWindow.label !== payload) return;
showWindow();
});
通过window插件,对webviewWindow
import {
LogicalPosition,
LogicalSize,
currentMonitor,
} from "@tauri-apps/api/window";
...
await appWindow.setSize(new LogicalSize(width, windowHeight));
await appWindow.setPosition(new LogicalPosition(x, y));
此项目前端是react编写,有很多关于插件的应用
https://gitee.com/hepengju/redis-gui
关于redis的rust实现
关于redis的做法参考,框架上没有什么可参考处
#[cfg(test)]
mod tests {
use redis::cluster::ClusterClient;
use redis::cluster_routing::RoutingInfo::MultiNode;
use redis::cluster_routing::{MultipleNodeRoutingInfo, ResponsePolicy};
use redis::{cluster, Commands, RedisResult, ScanOptions};
use MultipleNodeRoutingInfo::AllMasters;
use ResponsePolicy::AllSucceeded;
// 获取连接
fn get_conn() -> RedisResult<redis::Connection> {
let client = redis::Client::open("redis://:[email protected]:6379")?;
client.get_connection()
}
#[test]
fn info() -> RedisResult<()> {
let mut conn = get_conn()?;
let info: String = redis::cmd("info").query(&mut conn)?;
println!("Redis Info: {}", info);
Ok(())
}
#[test]
fn get_set() -> RedisResult<()> {
let mut conn = get_conn()?;
// 低级别api
let ack: String = redis::cmd("set").arg("rust:low:api").arg("低级别api").query(&mut conn)?;
println!("Redis Ack: {}", ack);
let ack: String = redis::cmd("get").arg("rust:low:api").query(&mut conn)?;
println!("Redis Ack: {}", ack);
// 高级别api
let ack: String = conn.set("rust:high:api", "高级别api")?;
println!("Redis Ack: {}", ack);
let ack: String = conn.get("rust:high:api")?;
println!("Redis Ack: {}", ack);
Ok(())
}
#[test]
fn scan() -> RedisResult<()> {
let mut conn = get_conn()?;
let keys: Vec<String> = conn.scan()?.collect();
println!("Keys: {:?}", keys);
let opts = ScanOptions::default().with_count(500).with_pattern("*rust*");
let keys: Vec<String> = conn.scan_options(opts)?.collect();
println!("Keys: {:?}", keys);
Ok(())
}
// 获取集群连接
fn get_cluster_conn() -> RedisResult<cluster::ClusterConnection> {
// 集群连接默认只传入1个节点即可
let nodes = vec!["redis://:[email protected]:7001"];
let client = ClusterClient::new(nodes)?;
client.get_connection()
}
#[test]
fn get_set_cluster() -> RedisResult<()> {
let mut conn = get_cluster_conn()?;
let ack: String = conn.set("rust:cluster:api", "集群连接")?;
println!("Redis Ack: {}", ack);
let ack: String = conn.get("rust:cluster:api")?;
println!("Redis Ack: {}", ack);
Ok(())
}
#[test]
fn scan_cluster() -> RedisResult<()> {
let routing_info = MultiNode((AllMasters, Some(AllSucceeded)));
Ok(())
}
}
https://gitee.com/cai-xinpenge/tauri_python
用tauri运行python
一个开屏界面的做法
"app": {
"withGlobalTauri": true,
"windows": [
{
"label": "splash",
"url": "splash/splash.html",
"width": 400,
"height": 200,
"center": true,
"decorations": false,
"resizable": false,
"alwaysOnTop": true,
"transparent": true
},
{
"label": "main",
"url": "index.html",
"visible": false,
"title": "tauri_python",
"width": 800,
"height": 600,
"center": true,
"decorations": true,
"resizable": true
}
],
"security": {
"csp": null
}
},
setup里有开屏软件的做法,也有线程的做法可以参考
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.setup(move |app| {
let queue = GLOBAL_QUEUE.clone();
let app_handle = app.handle().clone();
let config = create_message_queue_server_config(app_handle); // 4 个线程
// 创建服务端
let server = Arc::new(Mutex::new(Server::new(Arc::clone(&queue), config)));
// 启动服务端线程
let _server_thread = Arc::new(Mutex::new(Some(thread::spawn({
let server = Arc::clone(&server);
move || {
let server = server.lock().unwrap();
server.start();
}
}))));
let splash_window = app.get_webview_window("splash").unwrap();
let main_window = app.get_webview_window("main").unwrap();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(3)); // 模拟 3 秒加载时间
splash_window.close().unwrap(); // 关闭启动窗口
main_window.show().unwrap(); // 显示主窗口
});
Ok(())
})
.invoke_handler(tauri::generate_handler![run_playwright])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
https://gitee.com/chun22222222/tauri2ser
tauir连接串口
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serialport = "4.7.0"
regex = "1"
log = "0.4"
env_logger = "0.10"
fern = "0.6"
chrono = { version = "0.4", features = ["serde"] }
mod serial;
mod log_app;
use std::panic;
fn custom_panic_handler(info: &panic::PanicInfo) {
// 打印 panic 信息
log::error!("{}", info);
}
pub fn run() {
// 设置自定义的所有错误 或者 其他的 panic 信息的处理函数
panic::set_hook(Box::new(custom_panic_handler));
log_app::log_init();
tauri::Builder::default()
.manage(serial::SerialPortState::new())
.invoke_handler(tauri::generate_handler![
serial::connect_serial_port,
serial::disconnect_serial_port,
serial::send_msg,
])
//设置窗口的标题
.run(tauri::generate_context!())
.expect("error running tauri application");
}
通过app.emit(“serial_data”, serial_data).unwrap();发送串口信息到前端
// 查找换行符
while let Some(pos) = find_newline(&temp_buffer) {
let line = temp_buffer.drain(..pos + 1).collect::<Vec<u8>>(); // 提取一行数据,包括换行符
let serial_data = if is_hex {
line.iter().map(|b| format!("{:02X}", b)).collect::<Vec<String>>().join(" ")
} else {
match str::from_utf8(&line) {
Ok(valid_str) => valid_str.trim_end_matches(&['\r', '\n'][..]).to_string(),
Err(e) => {
eprintln!("Failed to decode UTF-8: {}", e);
continue;
}
}
};
//判断是否为空
if serial_data.is_empty() {
continue;
}
// 发送数据
app.emit("serial_data", serial_data).unwrap();
}
前端通过listen,接收到串口信息
import { invoke,Channel } from "@tauri-apps/api/core";
import { listen } from '@tauri-apps/api/event';
const showMsg = ref([]);
const msg_value = ref('');
const com_port = ref("COM3");
//获取当前时间,格式化到毫秒HH:mm:ss:SSS
const get_time = () => {
let date = new Date();
return date.toTimeString().split(' ')[0] + '.' + date.getMilliseconds();
}
listen('serial_data', (event) => {
//获取当前时间
showMsg.value.unshift({
code:0,
msg:event.payload,
time : get_time()
});
});
https://gitee.com/HuLaSpark/HuLa
即时通讯客户端软件
此demo支持5个平台(win,mac,linux,android,ios)
在 Tauri 的 tauri.conf.json 配置中,macOSPrivateApi 是一个布尔值选项,用于控制是否启用 macOS 的私有 API(非公开的、未正式支持的 API)。
默认值:false(禁用)
启用后 (true):
允许 Tauri 使用一些 macOS 特有的私有 API(如访问更深层的系统功能)。
⚠️ 风险:
这些 API 可能不稳定,未来 macOS 版本可能移除或更改它们。
可能导致 App Store 审核被拒(如果目标是上架 Mac App Store)。
仅推荐在需要特定 macOS 功能(如特殊窗口管理、系统集成)时使用。
capabilities 是 Tauri 安全配置中的一个数组,用于定义应用程序的 权限能力集(类似权限白名单)。
默认值
default-capability:基础能力(如文件系统访问、网络请求等)。
mobile-capability:移动端特有功能(如传感器、摄像头,适用于 Tauri 移动端开发)。
desktop-capability:桌面端特有功能(如系统托盘、全局快捷键)。
关键点
权限控制:Tauri 默认采用最小权限原则,capabilities 明确声明应用需要哪些权限。
自定义能力:你可以通过插件(如 tauri-plugin-name)扩展能力
"app": {
"withGlobalTauri": true,
"windows": [],
"security": {
"csp": null,
"capabilities": [
"default-capability",
"mobile-capability",
"desktop-capability"
]
},
"macOSPrivateApi": true
},
这几个窗口大小,作用,显示模式都有不同
"windows": [
{
"title": "登录",
"label": "login",
"url": "/login",
"fullscreen": false,
"resizable": false,
"center": true,
"width": 320,
"height": 448,
"skipTaskbar": false,
"transparent": true,
"visible": false,
"decorations": false
},
{
"label": "tray",
"url": "/tray",
"resizable": false,
"center": false,
"visible": false,
"width": 130,
"height": 44,
"alwaysOnTop": true,
"skipTaskbar": true,
"decorations": false,
"transparent": true
},
{
"label": "notify",
"url": "/notify",
"resizable": false,
"center": false,
"visible": false,
"width": 280,
"height": 140,
"alwaysOnTop": true,
"skipTaskbar": true,
"decorations": false,
"transparent": true
},
{
"label": "capture",
"url": "/capture",
"fullscreen": false,
"transparent": true,
"resizable": false,
"skipTaskbar": false,
"decorations": false,
"visible": false
},
{
"label": "checkupdate",
"url": "/checkupdate",
"resizable": false,
"width": 500,
"height": 150,
"alwaysOnTop": true,
"focus": true,
"skipTaskbar": true,
"visible": false,
"decorations": false,
"hiddenTitle": true
}
],
安装的组件
[dependencies]
tauri = { version = "2.5.1", features = [
"macos-private-api",
"tray-icon",
"image-png",
"rustls-tls",
] }
tauri-plugin-os = "2.2.1"
tauri-plugin-shell = "2.2.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri-plugin-http = { version = "2.4.3", features = [
"unsafe-headers",
"rustls-tls",
] }
tauri-plugin-process = "2.2.1"
tauri-plugin-fs = "2.2.1"
tauri-plugin-dialog = "2.2.1"
tauri-plugin-upload = "2.2.0"
tauri-plugin-global-shortcut = "2.2.0"
tauri-plugin-clipboard-manager = "2.2.2"
tauri-plugin-updater = "2.7.1"
tauri-plugin-sql = { version = "2.2.0", features = ["sqlite"] }
tauri-plugin-single-instance = "2.2.3"
tauri-plugin-notification = "2.2.2"
tungstenite = { version = "0.26.2", features = ["rustls-tls-webpki-roots"] }
tokio = { version = "1", features = ["full"] }
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
tauri-plugin-autostart = "2.3.0"
lazy_static = "1.4"
screenshots = "0.5.4"
base64 = "0.22.1"
rodio = "0.17.3"
reqwest = { version = "0.11", features = [
"json",
"socks",
"rustls-tls",
"blocking",
] }
surge-ping = "0.8.0"
rand = "0.8.5"
[target."cfg(target_os =\"macos\")".dependencies]
cocoa = "0.26.0"
objc = "0.2.7"
有很多权限配置,看下几个权限的配置
"core:webview:allow-create-webview",
"core:webview:allow-create-webview-window",
"core:webview:allow-internal-toggle-devtools",
"core:webview:allow-set-webview-size",
"core:webview:allow-webview-size",
...
"http:allow-fetch",
"http:allow-fetch-cancel",
"http:allow-fetch-read-body",
"http:allow-fetch-send",
看到不同平台的启动不同setup的写法
#[cfg(desktop)]
mod desktops;
引入了init_plugin等函数的写法,把plugin的注册放到其他文件中
pub fn run() {
#[cfg(desktop)]
{
setup_desktop();
}
#[cfg(mobile)]
{
setup_mobile();
}
}
#[cfg(desktop)]
fn setup_desktop() {
tauri::Builder::default()
.init_plugin()
.init_webwindow_event()
.init_window_event()
.setup(move |app| {
tray::create_tray(app.handle())?;
Ok(())
})
.invoke_handler(tauri::generate_handler![
default_window_icon,
screenshot,
audio,
set_height,
set_badge_count,
test_api_proxy,
test_ws_proxy,
#[cfg(target_os = "macos")]
hide_title_bar_buttons
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
前端通过WebviewWindow 获取当前窗口和其他窗口,进行显示或关闭
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
...
const appWindow = WebviewWindow.getCurrent()
...
const checkUpdateWindow = await WebviewWindow.getByLabel('checkupdate')
await checkUpdateWindow?.show()
...
const closeWindow = await WebviewWindow.getByLabel(event.close)
closeWindow?.close()
可以创建窗口,并可以指定父层
- 窗口层级关系(Z-Order)
子窗口(Child Window) 会始终显示在 父窗口 的上方。
当父窗口移动时,子窗口会跟随移动(模态窗口的常见行为)。
父窗口最小化时,子窗口也会被隐藏。- 模态窗口行为(Modal Window)
如果 parent 被指定,子窗口会默认成为 模态窗口(阻塞父窗口的交互,直到子窗口关闭)。
用户必须先处理子窗口,才能回到父窗口操作(类似对话框)。- 生命周期关联
如果父窗口关闭,子窗口也会自动关闭(除非单独设置了 closeOnParentClose: false)。
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
import { EventEnum } from '@/enums'
import { LogicalSize } from '@tauri-apps/api/dpi'
import { type } from '@tauri-apps/plugin-os'
import { UserAttentionType } from '@tauri-apps/api/window'
/** 判断是兼容的系统 */
const isCompatibility = computed(() => type() === 'windows' || type() === 'linux')
export const useWindow = () => {
/**
* 创建窗口
* @param title 窗口标题
* @param label 窗口名称
* @param wantCloseWindow 创建后需要关闭的窗口
* @param width 窗口宽度
* @param height 窗口高度
* @param resizable 调整窗口大小
* @param minW 窗口最小宽度
* @param minH 窗口最小高度
* @param transparent 是否透明
* @param visible 是否显示
* */
const createWebviewWindow = async (
title: string,
label: string,
width: number,
height: number,
wantCloseWindow?: string,
resizable = false,
minW = 330,
minH = 495,
transparent?: boolean,
visible = false
) => {
const checkLabel = computed(() => {
/** 如果是打开独立窗口就截取label中的固定label名称 */
if (label.includes(EventEnum.ALONE)) {
return label.replace(/\d/g, '')
} else {
return label
}
})
const webview = new WebviewWindow(label, {
title: title,
url: `/${checkLabel.value}`,
fullscreen: false,
resizable: resizable,
center: true,
width: width,
height: height,
minHeight: minH,
minWidth: minW,
skipTaskbar: false,
decorations: !isCompatibility.value,
transparent: transparent || isCompatibility.value,
titleBarStyle: 'overlay', // mac覆盖标签栏
hiddenTitle: true, // mac隐藏标题栏
visible: visible
})
await webview.once('tauri://created', async () => {
if (wantCloseWindow) {
const win = await WebviewWindow.getByLabel(wantCloseWindow)
win?.close()
}
})
await webview.once('tauri://error', async () => {
// TODO 这里利用错误处理的方式来查询是否是已经创建了窗口,如果一开始就使用WebviewWindow.getByLabel来查询在刷新的时候就会出现问题 (nyh -> 2024-03-06 23:54:17)
await checkWinExist(label)
})
return webview
}
/**
* 创建模态子窗口
* @param title 窗口标题
* @param label 窗口标识
* @param width 窗口宽度
* @param height 窗口高度
* @param parent 父窗口
* @returns 创建的窗口实例或已存在的窗口实例
*/
const createModalWindow = async (title: string, label: string, width: number, height: number, parent: string) => {
// 检查窗口是否已存在
const existingWindow = await WebviewWindow.getByLabel(label)
const parentWindow = parent ? await WebviewWindow.getByLabel(parent) : null
if (existingWindow) {
// 如果窗口已存在,则聚焦到现有窗口并使其闪烁
existingWindow.requestUserAttention(UserAttentionType.Critical)
return existingWindow
}
// 创建新窗口
const modalWindow = new WebviewWindow(label, {
url: `/${label}`,
title: title,
width: width,
height: height,
resizable: false,
center: true,
minWidth: 500,
minHeight: 500,
focus: true,
parent: parentWindow ? parentWindow : parent,
decorations: !isCompatibility.value,
transparent: isCompatibility.value,
titleBarStyle: 'overlay', // mac覆盖标签栏
hiddenTitle: true, // mac隐藏标题栏
visible: false
})
// 监听窗口创建完成事件
modalWindow.once('tauri://created', async () => {
if (type() === 'windows') {
// 禁用父窗口,模拟模态窗口效果
await parentWindow?.setEnabled(false)
}
// 设置窗口为焦点
await modalWindow.setFocus()
})
// 监听错误事件
modalWindow.once('tauri://error', async (e) => {
console.error(`${title}窗口创建失败:`, e)
window.$message?.error(`创建${title}窗口失败`)
await parentWindow?.setEnabled(true)
})
return modalWindow
}
/**
* 调整窗口大小
* @param label 窗口名称
* @param width 窗口宽度
* @param height 窗口高度
* */
const resizeWindow = async (label: string, width: number, height: number) => {
const webview = await WebviewWindow.getByLabel(label)
// 创建一个新的尺寸对象
const newSize = new LogicalSize(width, height)
// 调用窗口的 setSize 方法进行尺寸调整
await webview?.setSize(newSize).catch((error) => {
console.error('无法调整窗口大小:', error)
})
}
/**
* 检查窗口是否存在
* @param L 窗口标签
*/
const checkWinExist = async (L: string) => {
const isExistsWinds = await WebviewWindow.getByLabel(L)
if (isExistsWinds) {
nextTick().then(async () => {
// 如果窗口已存在,首先检查是否最小化了
const minimized = await isExistsWinds.isMinimized()
// 检查是否是隐藏
const hidden = await isExistsWinds.isVisible()
if (!hidden) {
await isExistsWinds.show()
}
if (minimized) {
// 如果已最小化,恢复窗口
await isExistsWinds.unminimize()
}
// 如果窗口已存在,则给它焦点,使其在最前面显示
await isExistsWinds.setFocus()
})
}
}
/**
* 设置窗口是否可调整大小
* @param label 窗口名称
* @param resizable 是否可调整大小
*/
const setResizable = async (label: string, resizable: boolean) => {
const webview = await WebviewWindow.getByLabel(label)
if (webview) {
await webview.setResizable(resizable).catch((error) => {
console.error('设置窗口可调整大小失败:', error)
})
}
}
return {
createWebviewWindow,
createModalWindow,
resizeWindow,
checkWinExist,
setResizable
}
}
const label = await WebviewWindow.getCurrent().label
if (route.name !== '/message' && label === 'home') {
router.push('/message')
}
...
const appWindow = WebviewWindow.getCurrent()
await pushListeners([
appWindow.listen(EventEnum.ALONE, () => {
emit(EventEnum.ALONE + itemRef.value?.roomId, itemRef.value)
if (aloneWin.value.has(EventEnum.ALONE + itemRef.value?.roomId)) return
aloneWin.value.add(EventEnum.ALONE + itemRef.value?.roomId)
}),
appWindow.listen(EventEnum.WIN_CLOSE, (e) => {
aloneWin.value.delete(e.payload)
})
])
http请求函数
import { fetch } from '@tauri-apps/plugin-http'
import { AppException, ErrorType } from '@/common/exception'
import { RequestQueue } from '@/utils/RequestQueue'
import urls from './urls'
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
import { URLEnum } from '@/enums'
// 错误信息常量
const ERROR_MESSAGES = {
NETWORK: '网络连接异常,请检查网络设置',
TIMEOUT: '请求超时,请稍后重试',
OFFLINE: '当前网络已断开,请检查网络连接',
ABORTED: '请求已取消',
UNKNOWN: '请求失败,请稍后重试'
} as const
/**
* @description 请求参数
* @property {"GET"|"POST"|"PUT"|"DELETE"} method 请求方法
* @property {Record} [headers] 请求头
* @property {Record} [query] 请求参数
* @property {any} [body] 请求体
* @property {boolean} [isBlob] 是否为Blob
* @property {RetryOptions} [retry] 重试选项
* @property {boolean} [noRetry] 是否禁用重试
* @return HttpParams
*/
export type HttpParams = {
method: 'GET' | 'POST' | 'PUT' | 'DELETE'
headers?: Record<string, string>
query?: Record<string, any>
body?: any
isBlob?: boolean
retry?: RetryOptions // 重试选项
noRetry?: boolean // 禁用重试
}
/**
* @description 重试选项
*/
export type RetryOptions = {
retries?: number
retryDelay?: (attempt: number) => number
retryOn?: number[]
}
/**
* @description 等待指定的毫秒数
* @param {number} ms 毫秒数
* @returns {Promise}
*/
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/**
* @description 判断是否应进行下一次重试
* @returns {boolean} 是否继续重试
*/
function shouldRetry(attempt: number, maxRetries: number, abort?: AbortController): boolean {
return attempt + 1 < maxRetries && !abort?.signal.aborted
}
/**
* TODO: 防止当有请求的时候突然退出登录,导致在登录窗口发生请求错误
* 检查是否需要阻止请求
* @param url 请求地址
* @returns 是否需要阻止请求
*/
const shouldBlockRequest = async (url: string) => {
try {
const currentWindow = WebviewWindow.getCurrent()
// TODO: 这里如果后续不需要token就可以发送请求还有在没有登录下的窗口都不需要阻止
const isLoginWindow = currentWindow.label === 'login' || 'register' || 'forgetPassword' || 'tray'
// 如果不是登录窗口,不阻止请求
if (!isLoginWindow) return false
// 登录相关的接口永远不阻止
if (url.includes(URLEnum.TOKEN) || url.includes(URLEnum.CAPTCHA)) return false
// 检查是否已登录成功(有双token)
const hasToken = localStorage.getItem('TOKEN')
const hasRefreshToken = localStorage.getItem('REFRESH_TOKEN')
const isLoggedIn = hasToken && hasRefreshToken
// 在登录窗口但已登录成功的情况下不阻止请求
return !isLoggedIn
} catch (error) {
console.error('检查请求状态失败:', error)
return false
}
}
/**
* @description HTTP 请求实现
* @template T
* @param {string} url 请求地址
* @param {HttpParams} options 请求参数
* @param {boolean} [fullResponse=false] 是否返回完整响应
* @param {AbortController} abort 中断器
* @returns {Promise} 请求结果
*/
async function Http<T = any>(
url: string,
options: HttpParams,
fullResponse: boolean = false,
abort?: AbortController
): Promise<{ data: T; resp: Response } | T> {
// 检查是否需要阻止请求
const shouldBlock = await shouldBlockRequest(url)
if (shouldBlock) {
throw new AppException('在登录窗口中,取消非登录相关请求', {
type: ErrorType.Network,
showError: false
})
}
// 打印请求信息
console.log(` 发起请求 → ${options.method} ${url}`, {
body: options.body,
query: options.query
})
// 默认重试配置,在登录窗口时禁用重试
const defaultRetryOptions: RetryOptions = {
retries: 3,
retryDelay: (attempt) => Math.pow(2, attempt) * 1000,
retryOn: [] // 状态码意味着已经连接到服务器
}
// 合并默认重试配置与用户传入的重试配置
const retryOptions: RetryOptions = {
...defaultRetryOptions,
...options.retry
}
const { retries = 3, retryDelay } = retryOptions
// 获取token和指纹
const token = localStorage.getItem('TOKEN')
//const fingerprint = await getEnhancedFingerprint()
// 构建请求头
const httpHeaders = new Headers(options.headers || {})
// 设置Content-Type
if (!httpHeaders.has('Content-Type') && !(options.body instanceof FormData)) {
httpHeaders.set('Content-Type', 'application/json')
}
// 设置Authorization
if (token) {
httpHeaders.set('Authorization', `Bearer ${token}`)
}
// 设置浏览器指纹
//if (fingerprint) {
//httpHeaders.set('X-Device-Fingerprint', fingerprint)
//}
// 构建 fetch 请求选项
const fetchOptions: RequestInit = {
method: options.method,
headers: httpHeaders,
signal: abort?.signal
}
// 获取代理设置
// const proxySettings = JSON.parse(localStorage.getItem('proxySettings') || '{}')
// 如果设置了代理,添加代理配置 (BETA)
// if (proxySettings.type && proxySettings.ip && proxySettings.port) {
// // 使用 Rust 后端的代理客户端
// fetchOptions.proxy = {
// url: `${proxySettings.type}://${proxySettings.ip}:${proxySettings.port}`
// }
// }
// 判断是否需要添加请求体
if (options.body) {
if (!(options.body instanceof FormData || options.body instanceof URLSearchParams)) {
fetchOptions.body = JSON.stringify(options.body)
} else {
fetchOptions.body = options.body // 如果是 FormData 或 URLSearchParams 直接使用
}
}
// 添加查询参数
if (options.query) {
const queryString = new URLSearchParams(options.query).toString()
url += `?${queryString}`
}
// 定义重试函数
let tokenRefreshCount = 0 // 在闭包中存储计数器
async function attemptFetch(currentAttempt: number): Promise<{ data: T; resp: Response } | T> {
try {
const response = await fetch(url, fetchOptions)
// 先判断是否连接到服务器,fetch请求是否成功,如果不成功那么就是本地客户端网络异常
if (!response.ok) {
throw new AppException(`HTTP error! status: ${response.status}`, {
type: ErrorType.Network,
code: response.status,
details: { url, method: options.method }
})
}
// 解析响应数据
const responseData = options.isBlob ? await response.arrayBuffer() : await response.json()
// 判断服务器返回的错误码进行操作
switch (responseData.code) {
case 401: {
console.log(' Token无效,清除token并重新登录...')
// 触发重新登录事件
window.dispatchEvent(new Event('needReLogin'))
break
}
case 403: {
console.log(' 权限不足')
break
}
case 422: {
break
}
case 40004: {
// 限制token刷新重试次数,最多重试一次
if (tokenRefreshCount >= 1) {
console.log(' Token刷新重试次数超过限制,退出重试')
window.dispatchEvent(new Event('needReLogin'))
throw new AppException('Token刷新失败', {
type: ErrorType.TokenExpired,
showError: true
})
}
try {
console.log(' 开始尝试刷新Token并重试请求')
const newToken = await refreshTokenAndRetry()
// 使用新token重试当前请求
httpHeaders.set('Authorization', `Bearer ${newToken}`)
console.log(' 使用新Token重试原请求')
// 增加计数器
tokenRefreshCount++
return attemptFetch(currentAttempt)
} catch (refreshError) {
// 续签出错也触发重新登录
window.dispatchEvent(new Event('needReLogin'))
throw refreshError
}
}
}
// 如果fecth请求成功,但是服务器请求不成功并且返回了错误,那么就抛出错误
if (responseData && !responseData.success) {
throw new AppException(responseData.msg || '服务器返回错误', {
type: ErrorType.Server,
code: response.status,
details: responseData,
showError: true
})
}
// 打印响应结果
console.log(`✅ 请求成功 → ${options.method} ${url}`, {
status: response.status,
data: responseData
})
// 若请求成功且没有业务错误
if (fullResponse) {
return { data: responseData, resp: response }
}
return responseData
} catch (error: any) {
// 优化错误日志,仅在开发环境打印详细信息
if (import.meta.env.DEV) {
console.error(`尝试 ${currentAttempt + 1} 失败 →`, error)
}
// 处理网络相关错误
if (
error instanceof TypeError || // fetch 的网络错误会抛出 TypeError
error.name === 'AbortError' || // 请求被中断
!navigator.onLine // 浏览器离线
) {
// 获取用户友好的错误信息
const errorMessage = getNetworkErrorMessage(error)
if (shouldRetry(currentAttempt, retries, abort)) {
console.warn(`${errorMessage},准备重试 → 第 ${currentAttempt + 2} 次尝试`)
const delayMs = retryDelay ? retryDelay(currentAttempt) : 1000
await wait(delayMs)
return attemptFetch(currentAttempt + 1)
}
// 重试次数用完,抛出友好的错误信息
throw new AppException(errorMessage, {
type: ErrorType.Network,
details: { attempts: currentAttempt + 1 },
showError: true
})
}
// 未知错误,使用友好的错误提示
throw new AppException(ERROR_MESSAGES.UNKNOWN, {
type: error instanceof TypeError ? ErrorType.Network : ErrorType.Unknown,
details: { attempts: currentAttempt + 1 },
showError: true
})
}
}
// 添加获取网络错误信息的辅助函数
function getNetworkErrorMessage(error: any): string {
if (!navigator.onLine) {
return ERROR_MESSAGES.OFFLINE
}
if (error.name === 'AbortError') {
return ERROR_MESSAGES.ABORTED
}
// 检查是否包含超时关键词
if (error.message?.toLowerCase().includes('timeout')) {
return ERROR_MESSAGES.TIMEOUT
}
return ERROR_MESSAGES.NETWORK
}
// 第一次执行,attempt=0
return attemptFetch(0)
}
// 添加一个标记,避免多个请求同时刷新token
let isRefreshing = false
// 使用队列实现
const requestQueue = new RequestQueue()
async function refreshTokenAndRetry(): Promise<string> {
if (isRefreshing) {
console.log(' 已有刷新请求在进行中,加入等待队列')
return new Promise((resolve) => {
// 可以根据请求类型设置优先级
requestQueue.enqueue(resolve, 1)
})
}
isRefreshing = true
try {
const refreshToken = localStorage.getItem('REFRESH_TOKEN')
if (!refreshToken) {
console.error('❌ 无刷新令牌')
throw new Error('无刷新令牌')
}
console.log(' 正在使用refreshToken获取新的token')
const response = await fetch(urls.refreshToken, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${refreshToken}`
},
body: JSON.stringify({ refreshToken })
})
const data = await response.json()
if (!response.ok || !data.success) {
// 重新登录
window.dispatchEvent(new Event('needReLogin'))
throw new Error('刷新令牌失败')
}
const { token, refreshToken: newRefreshToken } = data.data
console.log(' Token刷新成功,更新存储', data)
// 更新本地存储的token 知道
localStorage.setItem('TOKEN', token)
localStorage.setItem('REFRESH_TOKEN', newRefreshToken)
// 使用队列处理方式
await requestQueue.processQueue(token)
return token
} catch (error) {
console.error('❌ 刷新Token过程出错:', error)
requestQueue.clear() // 发生错误时清空队列
window.dispatchEvent(new Event('needReLogin'))
throw error
} finally {
isRefreshing = false
}
}
export default Http
websocket函数
import { WsResponseMessageType, WsTokenExpire } from '@/services/wsType.ts'
import type {
LoginSuccessResType,
LoginInitResType,
WsReqMsgContentType,
OnStatusChangeType,
UserStateType
} from '@/services/wsType.ts'
import type { MessageType, MarkItemType, RevokedMsgType } from '@/services/types'
import { OnlineEnum, ChangeTypeEnum, WorkerMsgEnum, ConnectionState } from '@/enums'
import { useMitt } from '@/hooks/useMitt.ts'
import { useUserStore } from '@/stores/user'
import { getEnhancedFingerprint } from '@/services/fingerprint.ts'
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
import { useTauriListener } from '@/hooks/useTauriListener'
import { listen } from '@tauri-apps/api/event'
import { useDebounceFn } from '@vueuse/core'
// 使用类型导入避免直接执行代码
import type { useNetworkReconnect as UseNetworkReconnectType } from '@/hooks/useNetworkReconnect'
// 创建 webSocket worker
const worker: Worker = new Worker(new URL('../workers/webSocket.worker.ts', import.meta.url), {
type: 'module'
})
// 创建 timer worker
const timerWorker: Worker = new Worker(new URL('../workers/timer.worker.ts', import.meta.url), {
type: 'module'
})
// 添加一个标识是否是主窗口的变量
let isMainWindow = false
// LRU缓存实现
class LRUCache<K, V> {
private maxSize: number
private cache = new Map<K, V>()
constructor(maxSize: number = 1000) {
this.maxSize = maxSize
}
set(key: K, value: V) {
if (this.cache.has(key)) {
this.cache.delete(key)
} else if (this.cache.size >= this.maxSize) {
const firstKey = this.cache.keys().next().value
if (firstKey) {
this.cache.delete(firstKey)
}
}
this.cache.set(key, value)
}
has(key: K): boolean {
return this.cache.has(key)
}
clear() {
this.cache.clear()
}
get size(): number {
return this.cache.size
}
}
class WS {
// 添加消息队列大小限制
readonly #MAX_QUEUE_SIZE = 50 // 减少队列大小
#tasks: WsReqMsgContentType[] = []
// 重连
#connectReady = false
// 使用LRU缓存替代简单的Set
#processedMsgCache = new LRUCache<number, number>(1000) // 使用LRU缓存
#tauriListener: ReturnType<typeof useTauriListener> | null = null
// 存储连接健康状态信息
#connectionHealth = {
isHealthy: true,
lastPongTime: null as number | null,
timeSinceLastPong: null as number | null
}
// 网络重连工具,延迟初始化
#networkReconnect: ReturnType<typeof UseNetworkReconnectType> | null = null
// 存储watch清理函数
#unwatchFunctions: (() => void)[] = []
constructor() {
this.initWindowType()
if (isMainWindow) {
this.initConnect()
// 收到WebSocket worker消息
worker.addEventListener('message', this.onWorkerMsg)
// 收到Timer worker消息
timerWorker.addEventListener('message', this.onTimerWorkerMsg)
// 添加页面可见性监听
this.initVisibilityListener()
this.initNetworkReconnect()
}
}
// 初始化网络重连工具
private initNetworkReconnect() {
// 动态导入以延迟执行
import('@/hooks/useNetworkReconnect')
.then(({ useNetworkReconnect }) => {
this.#networkReconnect = useNetworkReconnect()
console.log('[WebSocket] 网络重连工具初始化完成')
// 监听网络在线状态变化
if (this.#networkReconnect.isOnline) {
const unwatch = watch(this.#networkReconnect.isOnline, (newValue, oldValue) => {
// 只在网络从离线变为在线时执行重连
if (newValue === true && oldValue === false) {
console.log('[WebSocket] 网络恢复在线状态,主动重新初始化WebSocket连接')
// 重置重连计数并重新初始化连接
this.forceReconnect()
}
})
// 存储清理函数
this.#unwatchFunctions = this.#unwatchFunctions || []
this.#unwatchFunctions.push(unwatch)
}
})
.catch((err) => {
console.error('[WebSocket] 网络重连工具初始化失败:', err)
})
}
// 初始化页面可见性监听
private async initVisibilityListener() {
const handleVisibilityChange = (isVisible: boolean) => {
worker.postMessage(
JSON.stringify({
type: 'visibilityChange',
value: { isHidden: !isVisible }
})
)
// 优化的可见性恢复检查
if (isVisible && this.#networkReconnect?.isOnline?.value) {
// 检查最后一次通信时间,如果太久没有通信,刷新数据
const now = Date.now()
const lastPongTime = this.#connectionHealth.lastPongTime
const heartbeatTimeout = 90000 // 增加到90秒,减少误触发
if (lastPongTime && now - lastPongTime > heartbeatTimeout) {
console.log('[Network] 应用从后台恢复且长时间无心跳,刷新数据')
this.#networkReconnect?.refreshAllData()
}
}
}
const debouncedVisibilityChange = useDebounceFn((isVisible: boolean) => {
handleVisibilityChange(isVisible)
}, 300)
// 使用document.visibilitychange事件 兼容web
document.addEventListener('visibilitychange', () => {
const isVisible = !document.hidden
console.log(`document visibility change: ${document.hidden ? '隐藏' : '可见'}`)
debouncedVisibilityChange(isVisible)
})
// 跟踪当前窗口状态,避免无变化时重复触发
let currentVisibilityState = true
// 创建状态变更处理器
const createStateChangeHandler = (newState: boolean) => {
return () => {
if (currentVisibilityState !== newState) {
currentVisibilityState = newState
debouncedVisibilityChange(newState)
}
}
}
try {
// 设置各种Tauri窗口事件监听器
// 窗口失去焦点 - 隐藏状态
await listen('tauri://blur', createStateChangeHandler(false))
// 窗口获得焦点 - 可见状态
await listen('tauri://focus', createStateChangeHandler(true))
// 窗口最小化 - 隐藏状态
await listen('tauri://window-minimized', createStateChangeHandler(false))
// 窗口恢复 - 可见状态
await listen('tauri://window-restored', createStateChangeHandler(true))
// 窗口隐藏 - 隐藏状态
await listen('tauri://window-hidden', createStateChangeHandler(false))
// 窗口显示 - 可见状态
await listen('tauri://window-shown', createStateChangeHandler(true))
} catch (error) {
console.error('无法设置Tauri Window事件监听:', error)
}
}
// 处理Timer worker消息
onTimerWorkerMsg = (e: MessageEvent<any>) => {
const data = e.data
switch (data.type) {
case 'timeout': {
// 检查是否是心跳超时消息
if (data.msgId && data.msgId.startsWith('heartbeat_timeout_')) {
// 转发给WebSocket worker
worker.postMessage(JSON.stringify({ type: 'heartbeatTimeout' }))
}
// 处理任务队列定时器超时
else if (data.msgId === 'process_tasks_timer') {
const userStore = useUserStore()
if (userStore.isSign) {
// 处理堆积的任务
for (const task of this.#tasks) {
this.send(task)
}
// 清空缓存的消息
this.#tasks = []
}
}
break
}
case 'periodicHeartbeat': {
// 心跳触发,转发给WebSocket worker
worker.postMessage(JSON.stringify({ type: 'heartbeatTimerTick' }))
break
}
case 'reconnectTimeout': {
// timer上报重连超时事件,转发给WebSocket worker
console.log('重试次数: ', data.reconnectCount)
worker.postMessage(
JSON.stringify({
type: 'reconnectTimeout',
value: { reconnectCount: data.reconnectCount }
})
)
break
}
}
}
// 初始化窗口类型
private async initWindowType() {
const currentWindow = WebviewWindow.getCurrent()
isMainWindow = currentWindow.label === 'home'
}
initConnect = async () => {
const token = localStorage.getItem('TOKEN')
// 如果token 是 null, 而且 localStorage 的用户信息有值,需要清空用户信息
if (token === null && localStorage.getItem('user')) {
localStorage.removeItem('user')
}
const clientId = await getEnhancedFingerprint()
const savedProxy = localStorage.getItem('proxySettings')
let serverUrl = import.meta.env.VITE_WEBSOCKET_URL
if (savedProxy) {
const settings = JSON.parse(savedProxy)
const suffix = settings.wsIp + ':' + settings.wsPort + '/' + settings.wsSuffix
if (settings.wsType === 'ws' || settings.wsType === 'wss') {
serverUrl = settings.wsType + '://' + suffix
}
}
// 初始化 ws
worker.postMessage(
`{"type":"initWS","value":{"token":${token ? `"${token}"` : null},"clientId":${clientId ? `"${clientId}", "serverUrl":"${serverUrl}"` : null}}}`
)
}
onWorkerMsg = async (e: MessageEvent<any>) => {
const params: { type: string; value: unknown } = JSON.parse(e.data)
switch (params.type) {
case WorkerMsgEnum.MESSAGE: {
await this.onMessage(params.value as string)
break
}
case WorkerMsgEnum.OPEN: {
this.#dealTasks()
break
}
case WorkerMsgEnum.CLOSE:
case WorkerMsgEnum.ERROR: {
this.#onClose()
break
}
case WorkerMsgEnum.WS_ERROR: {
console.log('WebSocket错误:', (params.value as { msg: string }).msg)
useMitt.emit(WsResponseMessageType.NO_INTERNET, params.value)
// 如果是重连失败,可以提示用户刷新页面
if ((params.value as { msg: string }).msg.includes('连接失败次数过多')) {
// 可以触发UI提示,让用户刷新页面
useMitt.emit('wsReconnectFailed', params.value)
}
break
}
case 'startReconnectTimer': {
console.log('worker上报心跳超时事件', params.value)
// 向timer发送startReconnectTimer事件
timerWorker.postMessage({
type: 'startReconnectTimer',
reconnectCount: (params.value as any).reconnectCount as number,
value: { delay: 1000 }
})
break
}
// 心跳定时器相关消息处理
case 'startHeartbeatTimer': {
// 启动心跳定时器
const { interval } = params.value as { interval: number }
timerWorker.postMessage({
type: 'startPeriodicHeartbeat',
interval
})
break
}
case 'stopHeartbeatTimer': {
// 停止心跳定时器
timerWorker.postMessage({
type: 'stopPeriodicHeartbeat'
})
break
}
case 'startHeartbeatTimeoutTimer': {
// 启动心跳超时定时器
const { timerId, timeout } = params.value as { timerId: string; timeout: number }
timerWorker.postMessage({
type: 'startTimer',
msgId: timerId,
duration: timeout
})
break
}
case 'clearHeartbeatTimeoutTimer': {
// 清除心跳超时定时器
const { timerId } = params.value as { timerId: string }
timerWorker.postMessage({
type: 'clearTimer',
msgId: timerId
})
break
}
case 'connectionStateChange': {
const { state, isReconnection } = params.value as { state: ConnectionState; isReconnection: boolean }
// 检测重连成功
if (isReconnection && state === ConnectionState.CONNECTED) {
console.log(' WebSocket 重连成功')
// 网络重连成功后刷新数据
if (isMainWindow && this.#networkReconnect) {
console.log('开始刷新数据...')
this.#networkReconnect.refreshAllData()
} else if (isMainWindow) {
// 如果还没初始化,延迟初始化后再刷新
this.initNetworkReconnect()
}
} else if (!isReconnection && state === ConnectionState.CONNECTED) {
console.log('✅ WebSocket 首次连接成功')
}
break
}
// 处理心跳响应
case 'pongReceived': {
const { timestamp } = params.value as { timestamp: number }
this.#connectionHealth.lastPongTime = timestamp
break
}
// 处理连接健康状态
case 'connectionHealthStatus': {
const { isHealthy, lastPongTime, timeSinceLastPong } = params.value as {
isHealthy: boolean
lastPongTime: number | null
timeSinceLastPong: number | null
}
this.#connectionHealth = { isHealthy, lastPongTime, timeSinceLastPong }
useMitt.emit('wsConnectionHealthChange', this.#connectionHealth)
break
}
}
}
// 重置一些属性
#onClose = () => {
this.#connectReady = false
}
#dealTasks = () => {
this.#connectReady = true
// 先探测登录态
// this.#detectionLoginStatus()
timerWorker.postMessage({
type: 'startTimer',
msgId: 'process_tasks_timer',
duration: 500
})
}
#send(msg: WsReqMsgContentType) {
worker.postMessage(`{"type":"message","value":${typeof msg === 'string' ? msg : JSON.stringify(msg)}}`)
}
send = (params: WsReqMsgContentType) => {
if (isMainWindow) {
// 主窗口直接发送消息
if (this.#connectReady) {
this.#send(params)
} else {
// 优化的队列管理
if (this.#tasks.length >= this.#MAX_QUEUE_SIZE) {
// 优先丢弃非关键消息
const nonCriticalIndex = this.#tasks.findIndex(
(task) => typeof task === 'object' && task.type !== 1 && task.type !== 2
)
if (nonCriticalIndex !== -1) {
this.#tasks.splice(nonCriticalIndex, 1)
console.warn('消息队列已满,丢弃非关键消息')
} else {
this.#tasks.shift()
console.warn('消息队列已满,丢弃最旧消息')
}
}
this.#tasks.push(params)
}
}
}
// 收到消息回调
onMessage = async (value: string) => {
try {
const params: { type: WsResponseMessageType; data: unknown } = JSON.parse(value)
switch (params.type) {
// 获取登录二维码
case WsResponseMessageType.LOGIN_QR_CODE: {
console.log('获取二维码')
useMitt.emit(WsResponseMessageType.LOGIN_QR_CODE, params.data as LoginInitResType)
break
}
// 等待授权
case WsResponseMessageType.WAITING_AUTHORIZE: {
console.log('等待授权')
useMitt.emit(WsResponseMessageType.WAITING_AUTHORIZE)
break
}
// 登录成功
case WsResponseMessageType.LOGIN_SUCCESS: {
console.log('登录成功')
useMitt.emit(WsResponseMessageType.LOGIN_SUCCESS, params.data as LoginSuccessResType)
break
}
// 收到消息
case WsResponseMessageType.RECEIVE_MESSAGE: {
const message = params.data as MessageType
useMitt.emit(WsResponseMessageType.RECEIVE_MESSAGE, message)
break
}
// 用户状态改变
case WsResponseMessageType.USER_STATE_CHANGE: {
console.log('用户状态改变', params.data)
useMitt.emit(WsResponseMessageType.USER_STATE_CHANGE, params.data as UserStateType)
break
}
// 用户上线
case WsResponseMessageType.ONLINE: {
console.log('上线', params.data)
useMitt.emit(WsResponseMessageType.ONLINE, params.data as OnStatusChangeType)
break
}
// 用户下线
case WsResponseMessageType.OFFLINE: {
console.log('下线')
useMitt.emit(WsResponseMessageType.OFFLINE)
break
}
// 用户 token 过期
case WsResponseMessageType.TOKEN_EXPIRED: {
console.log('账号在其他设备登录')
useMitt.emit(WsResponseMessageType.TOKEN_EXPIRED, params.data as WsTokenExpire)
break
}
// 拉黑的用户的发言在禁用后,要删除他的发言
case WsResponseMessageType.INVALID_USER: {
console.log('无效用户')
useMitt.emit(WsResponseMessageType.INVALID_USER, params.data as { uid: number })
break
}
// 点赞、不满消息通知
case WsResponseMessageType.MSG_MARK_ITEM: {
useMitt.emit(WsResponseMessageType.MSG_MARK_ITEM, params.data as { markList: MarkItemType[] })
break
}
// 消息撤回通知
case WsResponseMessageType.MSG_RECALL: {
console.log('撤回')
useMitt.emit(WsResponseMessageType.MSG_RECALL, params.data as { data: RevokedMsgType })
break
}
// 新好友申请
case WsResponseMessageType.REQUEST_NEW_FRIEND: {
console.log('好友申请')
useMitt.emit(WsResponseMessageType.REQUEST_NEW_FRIEND, params.data as { uid: number; unreadCount: number })
break
}
// 成员变动
case WsResponseMessageType.NEW_FRIEND_SESSION: {
console.log('成员变动')
useMitt.emit(
WsResponseMessageType.NEW_FRIEND_SESSION,
params.data as {
roomId: number
uid: number
changeType: ChangeTypeEnum
activeStatus: OnlineEnum
lastOptTime: number
}
)
break
}
// 同意好友请求
case WsResponseMessageType.REQUEST_APPROVAL_FRIEND: {
console.log('同意好友申请', params.data)
useMitt.emit(
WsResponseMessageType.REQUEST_APPROVAL_FRIEND,
params.data as {
uid: number
}
)
break
}
// 自己修改我在群里的信息
case WsResponseMessageType.MY_ROOM_INFO_CHANGE: {
console.log('自己修改我在群里的信息', params.data)
useMitt.emit(
WsResponseMessageType.MY_ROOM_INFO_CHANGE,
params.data as {
myName: string
roomId: string
uid: string
}
)
break
}
case WsResponseMessageType.ROOM_INFO_CHANGE: {
console.log('群主修改群聊信息', params.data)
useMitt.emit(
WsResponseMessageType.ROOM_INFO_CHANGE,
params.data as {
roomId: string
name: string
avatar: string
}
)
break
}
case WsResponseMessageType.ROOM_GROUP_NOTICE_MSG: {
console.log('发布群公告', params.data)
useMitt.emit(
WsResponseMessageType.ROOM_GROUP_NOTICE_MSG,
params.data as {
id: string
content: string
top: string
}
)
break
}
case WsResponseMessageType.ROOM_EDIT_GROUP_NOTICE_MSG: {
console.log('编辑群公告', params.data)
useMitt.emit(
WsResponseMessageType.ROOM_EDIT_GROUP_NOTICE_MSG,
params.data as {
id: string
content: string
top: string
}
)
break
}
case WsResponseMessageType.ROOM_DISSOLUTION: {
console.log('群解散', params.data)
useMitt.emit(WsResponseMessageType.ROOM_DISSOLUTION, params.data)
break
}
default: {
console.log('接收到未处理类型的消息:', params)
break
}
}
} catch (error) {
console.error('Failed to parse WebSocket message:', error)
// 可以添加错误上报逻辑
return
}
}
// 检查连接健康状态
checkConnectionHealth() {
if (isMainWindow) {
worker.postMessage(
JSON.stringify({
type: 'checkConnectionHealth'
})
)
return this.#connectionHealth
}
return null
}
// 获取当前连接健康状态
getConnectionHealth() {
return this.#connectionHealth
}
// 强制重新连接WebSocket
forceReconnect() {
console.log('[WebSocket] 强制重新初始化WebSocket连接')
// 停止当前的重连计时器
worker.postMessage(JSON.stringify({ type: 'clearReconnectTimer' }))
// 停止心跳
worker.postMessage(JSON.stringify({ type: 'stopHeartbeat' }))
// 重置重连计数并重新初始化
worker.postMessage(JSON.stringify({ type: 'resetReconnectCount' }))
// 重新初始化连接
this.initConnect()
}
destroy() {
try {
// 优化的资源清理顺序
worker.postMessage(JSON.stringify({ type: 'clearReconnectTimer' }))
worker.postMessage(JSON.stringify({ type: 'stopHeartbeat' }))
// 同时终止timer worker相关的心跳
timerWorker.postMessage({
type: 'stopPeriodicHeartbeat'
})
// 清理内存
this.#tasks.length = 0 // 更高效的数组清空
this.#processedMsgCache.clear()
this.#connectReady = false
// 重置连接健康状态
this.#connectionHealth = {
isHealthy: true,
lastPongTime: null,
timeSinceLastPong: null
}
// 清理 Tauri 事件监听器
this.#tauriListener?.cleanup()
this.#tauriListener = null
// 清理所有watch
this.#unwatchFunctions.forEach((unwatch) => {
try {
unwatch()
} catch (error) {
console.warn('清理watch函数时出错:', error)
}
})
this.#unwatchFunctions.length = 0
// 最后终止workers
setTimeout(() => {
worker.terminate()
timerWorker.terminate()
}, 100) // 给一点时间让消息处理完成
} catch (error) {
console.error('销毁WebSocket时出错:', error)
}
}
}
export default new WS()
此案例有各类实现方式,如更新,托盘图标等,值得好好研究学习