feat(common): sqlite 本地存储 (#305)

* feat(store):  rust集成 sqlite

* chore(config): 🔨 移除一些不必要的文件

* feat(common):  群组信息 command

* feat(common):  rust 重构

* feat(common):  tauri 登录命令 存储 token

* feat(common):  tauri 存储 room 信息

* feat(common):  sqlite 存储房间信息,群成员信息

* feat(common):  修改我的群的信息

* feat(common):  游标分页查询群组成员

* feat(common):  查询全部群成员;优化前端显示逻辑

* feat(common):  更新 gitignore

* feat(common):  使用缓存检测是否强制更新本地数据,或采取静默更新

* fix(hook): 🐛 枚举使用错误修复

* feat(common):  sqlite 存储会话

* feat(common):  sqlite 存储聊天信息

* fix(common): 🐛 sqlite需要根据登录的用户 id 进行数据隔离

* fix(common): 🐛 添加 sqlite数据库迁移代码

* fix(view): 🐛 会话接口新增detailId;sqlite 数据结构变更;登录保存用户 id bug 修复;

* feat(common):  sqlite 存储消息

* fix(common): 🐛 sqlite 存储消息体异常

* feat(common):  添加 tauri 日志插件

* feat(common):  rust 层发送消息

* fix(component): 🐛 群成员监听显示问题;tauri 事件监听响应多次问题;消息列表显示顺序问题;

* fix(common): 🐛 im_message 表关闭 nickname 非空限制

* fix(common): 🐛 设置账号第一次在客户端登录,设置is_init 为 true,进行初始化获取近期所有消息

* fix(common): 🐛 配置 tauri 的环境变量,用于 migration 启动

* fix(common): 🐛 登录之后,聊天页面初始化,未显示聊天记录 bug

* feat(system):  合并master

* style(common): 💄 引入 tauri websocket; 格式化 rust 代码;

* fix(common): 🐛 ws 接收消息存储本地

* fix(common): 🐛 rust 报错没有在前端提示问题修复

* fix(common): 🐛 修复登录页面请求接口 bug,以及首屏骨架屏问题;

* fix(common): 🐛 token 更新bug修复

* feat(common):  离线消息同步

* fix(common): 🐛 将数据库迁移 crate 集成到主进程中

* style(common): 💄 修改 tauri 日志配置,输出日志到 logs 目录下;并对日志级别添加颜色;

* feat(common):  引入 tauri-devtools

* docs(common): 📝 env文件修改

* fix(common): 🐛 rust 依赖调整;配置调整;

* build(config): 📦 修改配置

---------

Co-authored-by: Dawn <2439646234@qq.com>
This commit is contained in:
wanwanruwoxin
2025-07-22 18:47:23 +08:00
committed by GitHub
parent 8c0ee4d755
commit 49aef65e5c
83 changed files with 9188 additions and 435 deletions
+2 -2
View File
@@ -10,5 +10,5 @@ VITE_APP_NAME="HuLa"
VITE_GITEE_TOKEN="a9029798336825cea39ac9e4413b8579"
# 启用本地的服务地址,先要注释掉上面的服务地址
# VITE_SERVICE_URL="http://127.0.0.1:9190"
# VITE_WEBSOCKET_URL="ws://127.0.0.1:8090/websocket"
# VITE_SERVICE_URL="http://192.168.1.24:9190"
# VITE_WEBSOCKET_URL="ws://192.168.1.24:8090/websocket"
+6 -1
View File
@@ -27,4 +27,9 @@ src-tauri/target
/coverage
.cursorrules
.claude/
.claude/
target
.claude/
*.sqlite
db.sqlite
+1
View File
@@ -67,6 +67,7 @@
"@tauri-apps/plugin-dialog": "^2.3.0",
"@tauri-apps/plugin-fs": "^2.4.0",
"@tauri-apps/plugin-http": "2.5.0",
"@tauri-apps/plugin-log": "^2.6.0",
"@tauri-apps/plugin-notification": "^2.3.0",
"@tauri-apps/plugin-opener": "^2.4.0",
"@tauri-apps/plugin-os": "2.3.0",
+10
View File
@@ -35,6 +35,9 @@ importers:
'@tauri-apps/plugin-http':
specifier: 2.5.0
version: 2.5.0
'@tauri-apps/plugin-log':
specifier: ^2.6.0
version: 2.6.0
'@tauri-apps/plugin-notification':
specifier: ^2.3.0
version: 2.3.0
@@ -1832,6 +1835,9 @@ packages:
'@tauri-apps/plugin-http@2.5.0':
resolution: {integrity: sha512-l4M2DUIsOBIMrbj4dJZwrB4mJiB7OA/2Tj3gEbX2fjq5MOpETklJPKfDvzUTDwuq4lIKCKKykz8E8tpOgvi0EQ==}
'@tauri-apps/plugin-log@2.6.0':
resolution: {integrity: sha512-gVp3l31akA1Jk2bZsTA0hMFD5/gLe49Nw1btu5lViau0QqgC2XyT79LSwvy7a44ewtQbSexchqIg7oTJKMIbXQ==}
'@tauri-apps/plugin-notification@2.3.0':
resolution: {integrity: sha512-QDwXo9VzAlH97c0veuf19TZI6cRBPfJDl2O6hNEDvI66j60lOO9z+PL6MJrj8A6Y+t55r7mGhe3rQWLmOre2HA==}
@@ -6779,6 +6785,10 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.6.0
'@tauri-apps/plugin-log@2.6.0':
dependencies:
'@tauri-apps/api': 2.6.0
'@tauri-apps/plugin-notification@2.3.0':
dependencies:
'@tauri-apps/api': 2.6.0
+2
View File
@@ -0,0 +1,2 @@
RUST_BACKTRACE=1
DATABASE_URL=sqlite:../db.sqlite?mode=rwc
+1292 -112
View File
File diff suppressed because it is too large Load Diff
+31 -13
View File
@@ -7,6 +7,13 @@ license = ""
repository = ""
edition = "2024"
[workspace]
resolver = "3"
members = [".", "entity", "migration"]
[workspace.dependencies]
entity = { path = "entity" }
[profile.release]
panic = "abort" # 去掉昂贵的恐慌清理逻辑
codegen-units = 1 # 一个接一个地编译包,这样编译器就可以更好地优化
@@ -56,17 +63,24 @@ tauri-plugin-mic-recorder = "2"
serde_json = "1"
sysinfo = "0.32.0"
async-walkdir = "2.0.0"
# 不兼容移动端的依赖
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
tauri-plugin-autostart = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-updater = "2"
mime_guess = "2.0.5"
moka = { version = "0.12.10", features = ["future"] }
serde = { version = "1", features = ["derive"] }
tokio = { version = "1.0.0", features = ["rt", "rt-multi-thread", "macros"] }
tauri-plugin-devtools = "2.0.0"
sea-orm = { version = "1.1.0", features = [ "sqlx-sqlite", "runtime-tokio-rustls", "macros" ] }
chrono = "0.4.41"
entity = { path = "entity" }
migration = { path = "migration" } # depends on your needs
anyhow = "1.0.98"
thiserror = "2.0.12"
config = { version = "0.15.11", default-features = false, features = ["yaml"] }
http = "0.2"
tauri-plugin-log = { version = "2.6.0", features = ["colored"] }
log = "0.4" # 基础日志接口
lazy_static = "1.4"
screenshots = "0.8.10"
mime_guess = "2.0.5"
base64 = "0.22.1"
rodio = "0.17.3"
image = { version = "0.25", features = ["jpeg", "png"] }
@@ -76,11 +90,15 @@ reqwest = { version = "0.11", features = [
"rustls-tls",
"blocking",
] }
tungstenite = { version = "0.26.2", features = ["rustls-tls-webpki-roots"] }
tokio = { version = "1", features = ["full"] }
futures = "0.3"
surge-ping = "0.8.0"
rand = "0.8.5"
# 不兼容移动端的依赖
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
tauri-plugin-autostart = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-updater = "2"
screenshots = "0.8.10"
# mac平台需要依赖
[target."cfg(target_os =\"macos\")".dependencies]
+1
View File
@@ -95,6 +95,7 @@
"opener:allow-reveal-item-in-dir",
"opener:allow-open-path",
"mic-recorder:default",
"log:default",
{
"identifier": "shell:allow-execute",
"allow": [
+4
View File
@@ -0,0 +1,4 @@
database:
sqlite_file: db.sqlite
backend:
base_url: https://hulaspark.com/api
+7
View File
@@ -0,0 +1,7 @@
# application:
# host: 127.0.0.1
# base_url: "http://127.0.0.1"
# database:
# require_ssl: false
# backend:
# base_url: http://localhost:9190
+4
View File
@@ -0,0 +1,4 @@
# application:
# host: 127.0.0.1
# backend:
# base_url: http://localhost:9190
+2389
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "entity"
version = "0.1.0"
edition = "2021"
[dependencies]
sea-orm = { version = "1.1.0" }
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
+22
View File
@@ -0,0 +1,22 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "im_config")]
#[serde(rename_all = "camelCase")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub config_key: String,
pub config_value: Option<String>,
#[serde(skip)]
#[sea_orm(primary_key)]
pub login_uid: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
+42
View File
@@ -0,0 +1,42 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "im_contact")]
#[serde(rename_all = "camelCase")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: String,
pub detail_id: String,
pub room_id: String,
#[serde(rename = "type")]
pub contact_type: Option<u8>,
pub hot_flag: Option<u8>,
pub top: Option<bool>,
pub account: Option<String>,
pub operate: Option<u8>,
pub remark: Option<String>,
pub my_name: Option<String>,
// "通知类型 0 -> 允许接受消息 1 -> 接收但不提醒[免打扰] 4 -> 已退出群聊"
pub mute_notification: Option<u8>,
// 删除会话
pub hide: Option<bool>,
pub active_time: Option<i64>,
// true->屏蔽 false -> 正常
pub shield: Option<bool>,
pub avatar: Option<String>,
#[serde(rename = "name")]
pub contact_name: Option<String>,
pub text: Option<String>,
pub unread_count: Option<u8>,
pub create_time: Option<i64>,
pub update_time: Option<i64>,
#[serde(skip)]
#[sea_orm(primary_key)]
pub login_uid: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
+30
View File
@@ -0,0 +1,30 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "im_message")]
#[serde(rename_all = "camelCase")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: String,
pub uid: String,
pub nickname: Option<String>,
pub room_id: String,
pub send_time: Option<i64>,
#[serde(rename = "type")]
pub message_type: Option<u8>,
pub body: Option<String>,
pub message_marks: Option<String>,
pub create_time: Option<i64>,
pub update_time: Option<i64>,
#[serde(skip)]
#[sea_orm(primary_key)]
pub login_uid: String,
/// 消息发送状态: pending, success, fail
pub send_status: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
+23
View File
@@ -0,0 +1,23 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "im_room")]
#[serde(rename_all = "camelCase")]
pub struct Model {
#[sea_orm(primary_key)]
#[serde(default)]
pub id: String,
pub room_id: String,
pub group_id: Option<String>,
pub room_name: String,
pub avatar: Option<String>,
#[serde(skip)]
#[sea_orm(primary_key)]
pub login_uid: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
+31
View File
@@ -0,0 +1,31 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "im_room_member")]
#[serde(rename_all = "camelCase")]
pub struct Model {
#[sea_orm(primary_key)]
#[serde(default)]
pub id: String,
pub room_id: Option<String>,
pub uid: Option<String>,
pub account: Option<String>,
pub my_name: Option<String>,
pub active_status: Option<u8>,
pub group_role: Option<i64>,
pub loc_place: Option<String>,
pub last_opt_time: i64,
pub create_time: Option<i64>,
pub name: String,
pub avatar: Option<String>,
pub user_state_id: Option<String>,
#[serde(skip)]
#[sea_orm(primary_key)]
pub login_uid: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
+36
View File
@@ -0,0 +1,36 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "im_user")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: String,
pub user_id: Option<i64>,
pub name: Option<String>,
pub avatar: Option<String>,
pub email: Option<String>,
pub account: Option<String>,
pub sex: Option<i32>,
pub open_id: Option<String>,
pub active_status: Option<i32>,
pub user_state_id: Option<i64>,
pub last_opt_time: Option<i64>,
pub ip_info: Option<Json>,
pub item_id: Option<i64>,
pub state: Option<i32>,
pub create_time: Option<i64>,
pub update_time: Option<i64>,
pub password: Option<String>,
pub avatar_update_time: Option<i64>,
pub num: Option<i32>,
pub context: Option<bool>,
pub user_type: Option<i32>,
pub is_init: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
+7
View File
@@ -0,0 +1,7 @@
pub mod im_config;
pub mod im_contact;
pub mod im_message;
pub mod im_room;
pub mod im_room_member;
pub mod im_user;
pub mod prelude;
+1
View File
@@ -0,0 +1 @@
pub use super::im_user::Entity as ImUserEntity;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+18
View File
@@ -6422,6 +6422,24 @@
"const": "http:deny-fetch-send",
"markdownDescription": "Denies the fetch_send command without any pre-configured scope."
},
{
"description": "Allows the log command\n#### This default permission set includes:\n\n- `allow-log`",
"type": "string",
"const": "log:default",
"markdownDescription": "Allows the log command\n#### This default permission set includes:\n\n- `allow-log`"
},
{
"description": "Enables the log command without any pre-configured scope.",
"type": "string",
"const": "log:allow-log",
"markdownDescription": "Enables the log command without any pre-configured scope."
},
{
"description": "Denies the log command without any pre-configured scope.",
"type": "string",
"const": "log:deny-log",
"markdownDescription": "Denies the log command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start-recording`\n- `allow-stop-recording`",
"type": "string",
+18
View File
@@ -6422,6 +6422,24 @@
"const": "http:deny-fetch-send",
"markdownDescription": "Denies the fetch_send command without any pre-configured scope."
},
{
"description": "Allows the log command\n#### This default permission set includes:\n\n- `allow-log`",
"type": "string",
"const": "log:default",
"markdownDescription": "Allows the log command\n#### This default permission set includes:\n\n- `allow-log`"
},
{
"description": "Enables the log command without any pre-configured scope.",
"type": "string",
"const": "log:allow-log",
"markdownDescription": "Enables the log command without any pre-configured scope."
},
{
"description": "Denies the log command without any pre-configured scope.",
"type": "string",
"const": "log:deny-log",
"markdownDescription": "Denies the log command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start-recording`\n- `allow-stop-recording`",
"type": "string",
+1887
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "migration"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
name = "migration"
path = "src/lib.rs"
[dependencies]
async-std = { version = "1", features = ["attributes", "tokio1"] }
entity.workspace = true
[dependencies.sea-orm-migration]
version = "1.1.0"
features = [
"sqlx-sqlite",
"runtime-tokio-rustls"
]
+41
View File
@@ -0,0 +1,41 @@
# Running Migrator CLI
- Generate a new migration file
```sh
cargo run -- generate MIGRATION_NAME
```
- Apply all pending migrations
```sh
cargo run
```
```sh
cargo run -- up
```
- Apply first 10 pending migrations
```sh
cargo run -- up -n 10
```
- Rollback last applied migrations
```sh
cargo run -- down
```
- Rollback last 10 applied migrations
```sh
cargo run -- down -n 10
```
- Drop all tables from the database, then reapply all migrations
```sh
cargo run -- fresh
```
- Rollback all applied migrations, then reapply all migrations
```sh
cargo run -- refresh
```
- Rollback all applied migrations
```sh
cargo run -- reset
```
- Check the status of all migrations
```sh
cargo run -- status
```
+12
View File
@@ -0,0 +1,12 @@
pub use sea_orm_migration::prelude::*;
mod m20220101_000001_create_table;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![Box::new(m20220101_000001_create_table::Migration)]
}
}
@@ -0,0 +1,306 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// 创建 im_user 表
manager
.create_table(
Table::create()
.table(ImUser::Table)
.if_not_exists()
.col(ColumnDef::new(ImUser::Id).string().not_null().primary_key())
.col(ColumnDef::new(ImUser::UserId).big_integer())
.col(ColumnDef::new(ImUser::Name).string())
.col(ColumnDef::new(ImUser::Avatar).string())
.col(ColumnDef::new(ImUser::Email).string())
.col(ColumnDef::new(ImUser::Account).string())
.col(ColumnDef::new(ImUser::Sex).integer())
.col(ColumnDef::new(ImUser::OpenId).string())
.col(ColumnDef::new(ImUser::ActiveStatus).integer())
.col(ColumnDef::new(ImUser::UserStateId).big_integer())
.col(ColumnDef::new(ImUser::LastOptTime).big_integer())
.col(ColumnDef::new(ImUser::IpInfo).json())
.col(ColumnDef::new(ImUser::ItemId).big_integer())
.col(ColumnDef::new(ImUser::State).integer())
.col(ColumnDef::new(ImUser::CreateTime).big_integer())
.col(ColumnDef::new(ImUser::UpdateTime).big_integer())
.col(ColumnDef::new(ImUser::Password).string())
.col(ColumnDef::new(ImUser::AvatarUpdateTime).big_integer())
.col(ColumnDef::new(ImUser::Num).integer())
.col(ColumnDef::new(ImUser::Context).boolean())
.col(ColumnDef::new(ImUser::UserType).integer())
.col(
ColumnDef::new(ImUser::IsInit)
.boolean()
.not_null()
.default(false),
)
.to_owned(),
)
.await?;
// 创建 im_contact 表
manager
.create_table(
Table::create()
.table(ImContact::Table)
.if_not_exists()
.col(ColumnDef::new(ImContact::Id).string().not_null())
.col(ColumnDef::new(ImContact::DetailId).string().not_null())
.col(ColumnDef::new(ImContact::RoomId).string().not_null())
.col(ColumnDef::new(ImContact::ContactType).tiny_unsigned())
.col(ColumnDef::new(ImContact::HotFlag).tiny_unsigned())
.col(ColumnDef::new(ImContact::Top).boolean())
.col(ColumnDef::new(ImContact::Account).string())
.col(ColumnDef::new(ImContact::Operate).tiny_unsigned())
.col(ColumnDef::new(ImContact::Remark).string())
.col(ColumnDef::new(ImContact::MyName).string())
.col(ColumnDef::new(ImContact::MuteNotification).tiny_unsigned())
.col(ColumnDef::new(ImContact::Hide).boolean())
.col(ColumnDef::new(ImContact::ActiveTime).big_integer())
.col(ColumnDef::new(ImContact::Shield).boolean())
.col(ColumnDef::new(ImContact::Avatar).string())
.col(ColumnDef::new(ImContact::ContactName).string())
.col(ColumnDef::new(ImContact::Text).string())
.col(ColumnDef::new(ImContact::UnreadCount).tiny_unsigned())
.col(ColumnDef::new(ImContact::CreateTime).big_integer())
.col(ColumnDef::new(ImContact::UpdateTime).big_integer())
.col(ColumnDef::new(ImContact::LoginUid).string().not_null())
.primary_key(Index::create().col(ImContact::Id).col(ImContact::LoginUid))
.to_owned(),
)
.await?;
// 创建 im_room 表
manager
.create_table(
Table::create()
.table(ImRoom::Table)
.if_not_exists()
.col(ColumnDef::new(ImRoom::Id).string().not_null())
.col(ColumnDef::new(ImRoom::RoomId).string().not_null())
.col(ColumnDef::new(ImRoom::GroupId).string())
.col(ColumnDef::new(ImRoom::RoomName).string().not_null())
.col(ColumnDef::new(ImRoom::Avatar).string())
.col(ColumnDef::new(ImRoom::LoginUid).string().not_null())
.primary_key(Index::create().col(ImRoom::Id).col(ImRoom::LoginUid))
.to_owned(),
)
.await?;
// 创建 im_room_member 表
manager
.create_table(
Table::create()
.table(ImRoomMember::Table)
.if_not_exists()
.col(ColumnDef::new(ImRoomMember::Id).string().not_null())
.col(ColumnDef::new(ImRoomMember::RoomId).string())
.col(ColumnDef::new(ImRoomMember::Uid).string())
.col(ColumnDef::new(ImRoomMember::Account).string())
.col(ColumnDef::new(ImRoomMember::MyName).string())
.col(ColumnDef::new(ImRoomMember::ActiveStatus).tiny_unsigned())
.col(ColumnDef::new(ImRoomMember::GroupRole).big_integer())
.col(ColumnDef::new(ImRoomMember::LocPlace).string())
.col(
ColumnDef::new(ImRoomMember::LastOptTime)
.big_integer()
.not_null(),
)
.col(ColumnDef::new(ImRoomMember::CreateTime).big_integer())
.col(ColumnDef::new(ImRoomMember::Name).string().not_null())
.col(ColumnDef::new(ImRoomMember::Avatar).string())
.col(ColumnDef::new(ImRoomMember::UserStateId).string())
.col(ColumnDef::new(ImRoomMember::LoginUid).string().not_null())
.primary_key(
Index::create()
.col(ImRoomMember::Id)
.col(ImRoomMember::LoginUid),
)
.to_owned(),
)
.await?;
// 创建 im_message 表
manager
.create_table(
Table::create()
.table(ImMessage::Table)
.if_not_exists()
.col(ColumnDef::new(ImMessage::Id).string().not_null())
.col(ColumnDef::new(ImMessage::Uid).string().not_null())
.col(ColumnDef::new(ImMessage::Nickname).string())
.col(ColumnDef::new(ImMessage::RoomId).string().not_null())
.col(ColumnDef::new(ImMessage::SendTime).big_integer())
.col(ColumnDef::new(ImMessage::MessageType).tiny_unsigned())
.col(ColumnDef::new(ImMessage::Body).string())
.col(ColumnDef::new(ImMessage::MessageMarks).string())
.col(ColumnDef::new(ImMessage::CreateTime).big_integer())
.col(ColumnDef::new(ImMessage::UpdateTime).big_integer())
.col(ColumnDef::new(ImMessage::LoginUid).string().not_null())
.col(
ColumnDef::new(ImMessage::SendStatus)
.string()
.not_null()
.default("pending"),
)
.primary_key(Index::create().col(ImMessage::Id).col(ImMessage::LoginUid))
.to_owned(),
)
.await?;
// 创建 im_config 表
manager
.create_table(
Table::create()
.table(ImConfig::Table)
.if_not_exists()
.col(ColumnDef::new(ImConfig::Id).big_integer().not_null())
.col(ColumnDef::new(ImConfig::ConfigKey).string().not_null())
.col(ColumnDef::new(ImConfig::ConfigValue).string())
.col(ColumnDef::new(ImConfig::LoginUid).string().not_null())
.primary_key(Index::create().col(ImConfig::Id).col(ImConfig::LoginUid))
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(ImConfig::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(ImMessage::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(ImRoomMember::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(ImRoom::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(ImContact::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(ImUser::Table).to_owned())
.await?;
Ok(())
}
}
// 定义表和列的枚举
#[derive(DeriveIden)]
enum ImUser {
Table,
Id,
UserId,
Name,
Avatar,
Email,
Account,
Sex,
OpenId,
ActiveStatus,
UserStateId,
LastOptTime,
IpInfo,
ItemId,
State,
CreateTime,
UpdateTime,
Password,
AvatarUpdateTime,
Num,
Context,
UserType,
IsInit,
}
#[derive(DeriveIden)]
enum ImContact {
Table,
Id,
DetailId,
RoomId,
ContactType,
HotFlag,
Top,
Account,
Operate,
Remark,
MyName,
MuteNotification,
Hide,
ActiveTime,
Shield,
Avatar,
ContactName,
Text,
UnreadCount,
CreateTime,
UpdateTime,
LoginUid,
}
#[derive(DeriveIden)]
enum ImRoom {
Table,
Id,
RoomId,
GroupId,
RoomName,
Avatar,
LoginUid,
}
#[derive(DeriveIden)]
enum ImRoomMember {
Table,
Id,
RoomId,
Uid,
Account,
MyName,
ActiveStatus,
GroupRole,
LocPlace,
LastOptTime,
CreateTime,
Name,
Avatar,
UserStateId,
LoginUid,
}
#[derive(DeriveIden)]
enum ImMessage {
Table,
Id,
Uid,
Nickname,
RoomId,
SendTime,
MessageType,
Body,
MessageMarks,
CreateTime,
UpdateTime,
LoginUid,
SendStatus,
}
#[derive(DeriveIden)]
enum ImConfig {
Table,
Id,
ConfigKey,
ConfigValue,
LoginUid,
}
+6
View File
@@ -0,0 +1,6 @@
use sea_orm_migration::prelude::*;
#[async_std::main]
async fn main() {
cli::run_cli(migration::Migrator).await;
}
+98
View File
@@ -0,0 +1,98 @@
use crate::AppData;
use crate::error::CommonError;
use crate::im_reqest_client::ImRequestClient;
use crate::repository::im_contact_repository::{list_contact, save_contact_batch};
use anyhow::Context;
use entity::im_contact;
use log::error;
use sea_orm::DatabaseConnection;
use std::ops::Deref;
use std::sync::Arc;
use tauri::State;
use tokio::sync::Mutex;
#[tauri::command]
pub async fn list_contacts_command(
state: State<'_, AppData>,
) -> Result<Vec<im_contact::Model>, String> {
let result: Result<Vec<im_contact::Model>, CommonError> = async {
// 获取当前登录用户的 uid
let login_uid = {
let user_info = state.user_info.lock().await;
user_info.uid.clone()
};
// 检查缓存中是否存在联系人列表数据
let cache_key = format!("contacts_list_{}", login_uid);
let is_cached = state.cache.get(&cache_key).await.is_some();
if !is_cached {
// 第一次查询:先调用后端接口,再更新本地数据库
let data = fetch_and_update_contacts(
state.db_conn.clone(),
state.request_client.clone(),
login_uid.clone(),
)
.await?;
// 设置缓存标记
state.cache.insert(cache_key, "cached".to_string()).await;
return Ok(data);
} else {
// 有缓存:从本地数据库获取数据
let local_contacts = list_contact(state.db_conn.deref(), &login_uid)
.await
.with_context(|| format!("[{}:{}] 本地数据库查询联系人失败", file!(), line!()))?;
// 异步调用后端接口更新本地数据库
let db_conn = state.db_conn.clone();
let request_client = state.request_client.clone();
let login_uid_clone = login_uid.clone();
tokio::spawn(async move {
if let Err(e) =
fetch_and_update_contacts(db_conn, request_client, login_uid_clone).await
{
error!("异步更新联系人数据失败: {:?}", e);
}
});
Ok(local_contacts)
}
}
.await;
match result {
Ok(contacts) => Ok(contacts),
Err(e) => {
error!("获取联系人列表失败: {:?}", e);
Err(e.to_string())
}
}
}
/// 获取并更新联系人数据
async fn fetch_and_update_contacts(
db_conn: Arc<DatabaseConnection>,
request_client: Arc<Mutex<ImRequestClient>>,
login_uid: String,
) -> Result<Vec<im_contact::Model>, CommonError> {
// 从后端API获取联系人数据
let resp = request_client
.lock()
.await
.get("/chat/contact/list")
.send_json::<Vec<im_contact::Model>>()
.await?;
if let Some(data) = resp.data {
// 保存到本地数据库
save_contact_batch(db_conn.deref(), data.clone(), &login_uid)
.await
.with_context(|| format!("[{}:{}] 保存联系人数据到本地数据库失败", file!(), line!()))?;
Ok(data)
} else {
Err(CommonError::UnexpectedError(anyhow::anyhow!(
"获取联系人数据失败"
)))
}
}
+409
View File
@@ -0,0 +1,409 @@
use crate::AppData;
use crate::error::CommonError;
use crate::im_reqest_client::ImRequestClient;
use crate::pojo::common::{CursorPageParam, CursorPageResp};
use crate::repository::{im_message_repository, im_user_repository};
use crate::vo::vo::ChatMessageReq;
use anyhow::Context;
use entity::im_user::Entity as ImUserEntity;
use entity::{im_message, im_user};
use log::{debug, error, info};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use sea_orm::{DatabaseConnection, TransactionTrait};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::ops::Deref;
use tauri::{AppHandle, Emitter, State};
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct MessageResp {
pub create_id: Option<String>,
pub create_time: Option<i64>,
pub update_id: Option<String>,
pub update_time: Option<i64>,
pub from_user: FromUser,
pub message: Message,
pub old_msg_id: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct FromUser {
pub uid: String,
pub nickname: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Message {
pub id: Option<String>,
pub room_id: Option<String>,
#[serde(rename = "type")]
pub message_type: Option<u8>,
pub body: Option<serde_json::Value>,
pub message_marks: Option<HashMap<String, MessageMark>>,
pub send_time: Option<i64>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct UrlInfo {
pub title: Option<String>,
pub description: Option<String>,
pub image: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct MergeMessage {
pub content: Option<String>,
pub created_time: Option<i64>,
pub name: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ReplyMsg {
pub id: Option<String>,
pub uid: Option<String>,
pub username: Option<String>,
#[serde(rename = "type")]
pub msg_type: Option<u8>,
pub body: Option<Box<serde_json::Value>>,
pub can_callback: u8,
pub gap_count: u32,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct MessageMark {
pub count: u32,
pub user_marked: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct CursorPageMessageParam {
room_id: String,
#[serde(flatten)]
cursor_page_param: CursorPageParam,
}
#[tauri::command]
pub async fn page_msg(
param: CursorPageMessageParam,
state: State<'_, AppData>,
) -> Result<CursorPageResp<Vec<MessageResp>>, String> {
// 获取当前登录用户的 uid
let login_uid = {
let user_info = state.user_info.lock().await;
user_info.uid.clone()
};
// 从数据库查询消息
let db_result = im_message_repository::cursor_page_messages(
state.db_conn.deref(),
param.room_id,
param.cursor_page_param,
&login_uid,
)
.await
.map_err(|e| e.to_string())?;
// 转换数据库模型为响应模型
let message_resps: Vec<MessageResp> = db_result
.list
.unwrap_or_default()
.into_iter()
.map(|msg| convert_message_to_resp(msg))
.rev()
.collect();
Ok(CursorPageResp {
cursor: db_result.cursor,
is_last: db_result.is_last,
list: Some(message_resps),
total: db_result.total,
})
}
/// 将数据库消息模型转换为响应模型
fn convert_message_to_resp(msg: im_message::Model) -> MessageResp {
// 解析消息体
let body = msg.body.as_ref().and_then(|b| serde_json::from_str(b).ok());
// 解析消息标记
let message_marks = msg
.message_marks
.as_ref()
.and_then(|marks| serde_json::from_str::<HashMap<String, MessageMark>>(marks).ok());
MessageResp {
create_id: Some(msg.id.clone()),
create_time: msg.send_time,
update_id: None,
update_time: None,
from_user: FromUser {
uid: msg.uid,
nickname: msg.nickname,
},
message: Message {
id: Some(msg.id),
room_id: Some(msg.room_id),
message_type: msg.message_type,
body,
message_marks,
send_time: msg.send_time,
},
old_msg_id: None,
}
}
/// 检查用户初始化状态并获取消息
pub async fn check_user_init_and_fetch_messages(
client: &ImRequestClient,
db_conn: &DatabaseConnection,
uid: &str,
) -> Result<(), CommonError> {
debug!("检查用户初始化状态并获取消息, uid: {}", uid);
// 检查用户的 is_init 状态
if let Ok(user) = ImUserEntity::find()
.filter(im_user::Column::Id.eq(uid))
.one(db_conn)
.await
{
if let Some(user_model) = user {
// 如果 is_init 为 true,调用后端接口获取所有消息
if user_model.is_init {
info!("用户 {} 需要初始化,开始获取所有消息", uid);
// 传递用户的 last_opt_time 参数
if let Err(e) = fetch_all_messages(client, db_conn, uid, None).await {
error!("获取所有消息失败: {}", e);
return Err(e);
}
}else {
info!("用户 {} 离线消息更新, last_opt_time: {:?}", uid, user_model.last_opt_time);
if let Err(e) = fetch_all_messages(client, db_conn, uid, user_model.last_opt_time).await {
error!("离线消息更新失败: {}", e);
return Err(e);
}
}
}
}
Ok(())
}
/// 从后端获取所有消息并保存到数据库
pub async fn fetch_all_messages(
client: &ImRequestClient,
db_conn: &DatabaseConnection,
uid: &str,
last_opt_time: Option<i64>,
) -> Result<(), CommonError> {
// 调用后端接口 /chat/msg/list 获取所有消息,传递 last_opt_time 参数
let mut request = client.get("/chat/msg/list");
// 如果有 last_opt_time 参数,添加到查询参数中
if let Some(time) = last_opt_time {
request = request.query(&[("lastOptTime", time.to_string())]);
}
let messages = request
.send_json::<Vec<MessageResp>>()
.await?;
if let Some(messages) = messages.data {
// 开启事务
let tx = db_conn.begin().await?;
// 转换 MessageResp 为 im_message::Model
let db_messages: Vec<im_message::Model> = messages
.into_iter()
.map(|msg_resp| convert_resp_to_model_for_fetch(msg_resp, uid.to_string()))
.collect();
// 保存到本地数据库
match im_message_repository::save_all(&tx, db_messages).await {
Ok(_) => {
info!("消息保存到数据库成功");
}
Err(e) => {
error!("保存消息到数据库失败,详细错误: {:?}", e);
return Err(e.into());
}
}
// 消息保存完成后,将用户的 is_init 状态设置为 false
im_user_repository::update_user_init_status(&tx, uid, false)
.await
.with_context(|| "更新用户 is_init 状态失败")?;
// 提交事务
tx.commit().await?;
}
Ok(())
}
/// 将 MessageResp 转换为数据库模型(用于 fetch_all_messages
fn convert_resp_to_model_for_fetch(msg_resp: MessageResp, uid: String) -> im_message::Model {
use serde_json;
// 序列化消息体为 JSON 字符串
let body_json = msg_resp
.message
.body
.as_ref()
.and_then(|body| serde_json::to_string(body).ok());
// 序列化消息标记为 JSON 字符串
let marks_json = msg_resp
.message
.message_marks
.as_ref()
.and_then(|marks| serde_json::to_string(marks).ok());
im_message::Model {
id: msg_resp.message.id.unwrap_or_default(),
uid: msg_resp.from_user.uid,
nickname: msg_resp.from_user.nickname,
room_id: msg_resp.message.room_id.unwrap_or_default(),
message_type: msg_resp.message.message_type,
body: body_json,
message_marks: marks_json,
send_time: msg_resp.message.send_time,
create_time: msg_resp.create_time,
update_time: msg_resp.update_time,
login_uid: uid.to_string(), // 这里暂时设为空字符串,实际使用时会在 save_all 中设置
send_status: "success".to_string(), // 从后端获取的消息默认为成功状态
}
}
#[tauri::command]
pub async fn send_msg(
data: ChatMessageReq,
state: State<'_, AppData>,
app: AppHandle,
) -> Result<(), String> {
use std::ops::Deref;
// 获取当前登录用户信息
let (login_uid, nickname) = {
let user_info = state.user_info.lock().await;
(user_info.uid.clone(), None) // UserInfo只有uid和token字段,nickname暂时设为None
};
// 生成消息ID
let current_time = chrono::Utc::now().timestamp_millis();
// 先克隆data以避免所有权问题
let send_data = data.clone();
// 序列化消息体
let body_json = data
.body
.as_ref()
.and_then(|body| serde_json::to_string(body).ok());
// 创建消息模型
let message = im_message::Model {
id: data.id.clone(),
uid: login_uid.clone(),
nickname,
room_id: data.room_id.unwrap_or_default(),
message_type: data.msg_type,
body: body_json,
message_marks: None,
send_time: Some(current_time),
create_time: Some(current_time),
update_time: Some(current_time),
login_uid: login_uid.clone(),
send_status: "pending".to_string(), // 初始状态为pending
};
let tx = state
.db_conn
.begin()
.await
.map_err(|e| CommonError::DatabaseError(e))?;
// 先保存到本地数据库
if let Err(e) = im_message_repository::save_message(&tx, message.clone()).await {
error!("保存消息到数据库失败: {}", e);
return Err(e.to_string());
}
tx.commit()
.await
.map_err(|e| CommonError::DatabaseError(e))?;
info!("消息已保存到本地数据库,ID: {}", message.id.clone());
// 异步发送到后端接口
let db_conn = state.db_conn.clone();
let request_client = state.request_client.clone();
let msg_id = message.id.clone();
tokio::spawn(async move {
// 发送到后端接口
let result = {
let client = request_client.lock().await;
client
.post("/chat/msg")
.json(&send_data)
.send_json::<MessageResp>()
.await
};
let mut id = None;
// 根据发送结果更新消息状态
let status = match result {
Ok(resp) => {
info!("消息发送成功,ID: {}", msg_id);
let mut result = resp.data.clone().unwrap();
result.old_msg_id = Some(msg_id.clone());
id = result.message.id.clone();
let _ = app.emit::<MessageResp>("send_msg_success", result);
"success"
}
Err(e) => {
error!("消息发送失败,ID: {}, 错误: {}", msg_id, e);
let _ = app.emit::<String>("send_msg_error", msg_id.clone());
"fail"
}
};
// 更新消息状态
if let Err(e) = im_message_repository::update_message_status(
db_conn.deref(),
&msg_id,
status,
id,
login_uid.clone(),
)
.await
{
error!("更新消息状态失败: {}", e);
}
});
Ok(())
}
#[tauri::command]
pub async fn save_msg(data: MessageResp, state: State<'_, AppData>) -> Result<(), String> {
info!("收到消息保存到本地, data: {:?}", data);
// 创建 im_message::Model
let message = convert_resp_to_model_for_fetch(data, state.user_info.lock().await.uid.clone());
async {
let tx = state.db_conn.clone().begin().await?;
// 保存到数据库
im_message_repository::save_message(&tx, message).await?;
tx.commit().await?;
Ok::<(), CommonError>(())
}
.await?;
Ok(())
}
+4
View File
@@ -0,0 +1,4 @@
pub mod contact_command;
pub mod message_command;
pub mod room_member_command;
pub mod user_command;
@@ -0,0 +1,333 @@
use crate::AppData;
use crate::error::CommonError;
use crate::pojo::common::{CursorPageParam, CursorPageResp, Page, PageParam};
use crate::repository::im_room_member_repository::{
get_room_members_by_room_id, get_room_page, save_room_batch, save_room_member_batch,
update_my_room_info as update_my_room_info_db,
};
use crate::vo::vo::MyRoomInfoReq;
use anyhow::Context;
use entity::{im_room, im_room_member};
use log::{error, info};
use crate::im_reqest_client::ImRequestClient;
use crate::repository::im_room_member_repository;
use sea_orm::DatabaseConnection;
use serde::{Deserialize, Serialize};
use std::ops::Deref;
use std::sync::Arc;
use tauri::State;
use tokio::sync::Mutex;
#[tauri::command]
pub async fn update_my_room_info(
my_room_info: MyRoomInfoReq,
state: State<'_, AppData>,
) -> Result<(), String> {
let result: Result<(), CommonError> = async {
// 获取当前用户信息
let user_info = state.user_info.lock().await;
let uid = user_info.uid.clone();
drop(user_info);
// 调用后端接口更新房间信息
let _resp = state
.request_client
.lock()
.await
.post("/room/updateMyRoomInfo")
.json(&my_room_info)
.send_json::<bool>()
.await
.with_context(|| format!("[{}:{}] 调用后端接口更新房间信息失败", file!(), line!()))?;
// 更新本地数据库
update_my_room_info_db(
state.db_conn.deref(),
&my_room_info.my_name,
&my_room_info.id,
&uid,
&uid,
)
.await
.with_context(|| format!("[{}:{}] 更新本地数据库失败", file!(), line!()))?;
Ok(())
}
.await;
match result {
Ok(members) => Ok(members),
Err(e) => {
error!("更新房间信息失败: {:?}", e);
Err(e.to_string())
}
}
}
/// 获取room_id的房间的所有成员列表
#[tauri::command]
pub async fn get_room_members(
room_id: String,
state: State<'_, AppData>,
) -> Result<Vec<im_room_member::Model>, String> {
info!("调用获取room_id的房间的所有成员列表");
let result: Result<Vec<im_room_member::Model>, CommonError> = async {
// 检查缓存中是否存在该room_id
let cache_key = format!("room_members_{}", room_id);
let is_cached = state.cache.get(&cache_key).await.is_some();
if !is_cached {
// 获取当前登录用户的 uid
let login_uid = {
let user_info = state.user_info.lock().await;
user_info.uid.clone()
};
let mut data = fetch_and_update_room_members(
room_id.clone(),
state.db_conn.clone(),
state.request_client.clone(),
login_uid.clone(),
)
.await?;
// 设置缓存标记
state.cache.insert(cache_key, "cached".to_string()).await;
// 对从后端获取的数据进行排序
sort_room_members(&mut data);
return Ok(data);
} else {
// 获取当前登录用户的 uid
let login_uid = {
let user_info = state.user_info.lock().await;
user_info.uid.clone()
};
// 有缓存:从本地数据库获取数据
let mut local_members =
get_room_members_by_room_id(&room_id, state.db_conn.deref(), &login_uid)
.await
.with_context(|| {
format!("[{}:{}] 本地数据库查询房间成员失败", file!(), line!())
})?;
// 对查询结果进行排序
sort_room_members(&mut local_members);
// 异步调用后端接口更新本地数据库
let db_conn = state.db_conn.clone();
let request_client = state.request_client.clone();
let room_id_clone = room_id.clone();
let login_uid_clone = login_uid.clone();
tokio::spawn(async move {
if let Err(e) = fetch_and_update_room_members(
room_id_clone,
db_conn,
request_client,
login_uid_clone,
)
.await
{
error!("异步更新房间成员数据失败: {:?}", e);
}
});
Ok(local_members)
}
}
.await;
match result {
Ok(members) => Ok(members),
Err(e) => {
error!("获取房间全部成员数据失败: {:?}", e);
Err(e.to_string())
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct CursorPageRoomMemberParam {
room_id: String,
#[serde(flatten)]
cursor_page_param: CursorPageParam,
}
// 游标分页查询数据
#[tauri::command]
pub async fn cursor_page_room_members(
param: CursorPageRoomMemberParam,
state: State<'_, AppData>,
) -> Result<CursorPageResp<Vec<im_room_member::Model>>, String> {
// 获取当前登录用户的 uid
let login_uid = {
let user_info = state.user_info.lock().await;
user_info.uid.clone()
};
let data = im_room_member_repository::cursor_page_room_members(
state.db_conn.deref(),
param.room_id,
param.cursor_page_param,
&login_uid,
)
.await
.map_err(|e| e.to_string())?;
Ok(data)
}
/// 从本地数据库分页查询房间数据,如果为空则从后端获取
#[tauri::command]
pub async fn page_room(
page_param: PageParam,
state: State<'_, AppData>,
) -> Result<Page<im_room::Model>, String> {
let result: Result<Page<im_room::Model>, CommonError> = async {
// 检查缓存中是否存在房间列表数据
let cache_key = format!("room_list_page_{}_{}", page_param.current, page_param.size);
let is_cached = state.cache.get(&cache_key).await.is_some();
if !is_cached {
// 获取当前登录用户的 uid
let login_uid = {
let user_info = state.user_info.lock().await;
user_info.uid.clone()
};
// 第一次查询:先调用后端接口,再更新本地数据库
let data = fetch_and_update_rooms(
page_param.clone(),
state.db_conn.clone(),
state.request_client.clone(),
login_uid,
)
.await?;
// 设置缓存标记
state.cache.insert(cache_key, "cached".to_string()).await;
return Ok(data);
} else {
// 获取当前登录用户的 uid
let login_uid = {
let user_info = state.user_info.lock().await;
user_info.uid.clone()
};
// 有缓存:从本地数据库获取数据
let local_result = get_room_page(page_param.clone(), state.db_conn.deref(), &login_uid)
.await
.with_context(|| format!("[{}:{}] 本地数据库查询失败", file!(), line!()))?;
// 异步调用后端接口更新本地数据库
let db_conn = state.db_conn.clone();
let request_client = state.request_client.clone();
let page_param_clone = page_param.clone();
let login_uid_clone = login_uid.clone();
tokio::spawn(async move {
if let Err(e) = fetch_and_update_rooms(
page_param_clone,
db_conn,
request_client,
login_uid_clone,
)
.await
{
error!("异步更新房间数据失败: {:?}", e);
}
});
Ok(local_result)
}
}
.await;
match result {
Ok(page_data) => Ok(page_data),
Err(e) => {
error!("分页获取房间数据失败: {:?}", e);
Err(e.to_string())
}
}
}
/// 对房间成员列表进行排序:在线用户优先(active_status=1),相同状态下按last_opt_time降序
fn sort_room_members(members: &mut Vec<im_room_member::Model>) {
members.sort_by(|a, b| {
let a_status = a.active_status.unwrap_or(0);
let b_status = b.active_status.unwrap_or(0);
// 先按active_status升序排序(在线用户优先)
match a_status.cmp(&b_status) {
std::cmp::Ordering::Equal => {
// active_status相同时,按last_opt_time降序排序
b.last_opt_time.cmp(&a.last_opt_time)
}
other => other,
}
});
}
/// 异步更新房间成员数据
async fn fetch_and_update_room_members(
room_id: String,
db_conn: Arc<DatabaseConnection>,
request_client: Arc<Mutex<ImRequestClient>>,
login_uid: String,
) -> Result<Vec<im_room_member::Model>, CommonError> {
// 从后端API获取最新数据
let resp = request_client
.lock()
.await
.get("/room/group/listMember")
.query(&[("roomId", &room_id)])
.send_json::<Vec<im_room_member::Model>>()
.await?;
// 更新本地数据库
if let Some(data) = resp.data {
if !data.is_empty() {
let room_id_i64 = room_id.parse::<i64>().unwrap_or(0);
save_room_member_batch(db_conn.deref(), data.clone(), room_id_i64, &login_uid)
.await
.with_context(|| {
format!(
"[{}:{}] 异步更新房间成员数据到本地数据库失败",
file!(),
line!()
)
})?;
return Ok(data.clone());
}
}
Ok(Vec::new())
}
/// 获取并更新房间数据
async fn fetch_and_update_rooms(
page_param: PageParam,
db_conn: Arc<DatabaseConnection>,
request_client: Arc<Mutex<ImRequestClient>>,
login_uid: String,
) -> Result<Page<im_room::Model>, CommonError> {
// 从后端API获取数据
let resp = request_client
.lock()
.await
.get("/room/group/list")
.query(&page_param)
.send_json::<Page<im_room::Model>>()
.await?;
if let Some(data) = resp.data {
// 保存到本地数据库
save_room_batch(db_conn.deref(), data.records.clone(), &login_uid)
.await
.with_context(|| format!("[{}:{}] 保存房间数据到本地数据库失败", file!(), line!()))?;
Ok(data)
} else {
Err(CommonError::UnexpectedError(anyhow::anyhow!(
"获取房间数据失败"
)))
}
}
+80
View File
@@ -0,0 +1,80 @@
use crate::AppData;
use chrono::{Local};
use entity::im_user;
use entity::prelude::ImUserEntity;
use log::{debug, info};
use sea_orm::ActiveValue::Set;
use sea_orm::ColumnTrait;
use sea_orm::EntityTrait;
use sea_orm::IntoActiveModel;
use sea_orm::QueryFilter;
use serde::{Deserialize, Serialize};
use std::ops::Deref;
use tauri::State;
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct UserInfo {
uid: String,
}
#[tauri::command]
pub async fn save_user_info(user_info: UserInfo, state: State<'_, AppData>) -> Result<(), String> {
let db = state.db_conn.clone();
// 检查用户是否存在
let exists = ImUserEntity::find()
.filter(im_user::Column::Id.eq(&user_info.uid))
.one(db.deref())
.await
.map_err(|err| format!("查询用户失败: {}", err))?;
if exists.is_none() {
info!("用户不存在,准备插入新用户");
let user = im_user::ActiveModel {
id: Set(user_info.uid.clone()),
// TODO 这里先设置为 true,后续需要根据配置调整
is_init: Set(true),
..Default::default()
};
im_user::Entity::insert(user)
.exec(db.deref())
.await
.map_err(|err| format!("插入用户失败: {}", err))?;
} else {
debug!("用户已存在,无需插入");
}
Ok(())
}
#[tauri::command]
pub async fn update_user_last_opt_time(
state: State<'_, AppData>,
) -> Result<(), String> {
info!("更新用户最后操作时间");
let db = state.db_conn.clone();
let uid = state.user_info.lock().await.uid.clone();
// 检查用户是否存在
let user = ImUserEntity::find()
.filter(im_user::Column::Id.eq(uid.clone()))
.one(db.deref())
.await
.map_err(|err| format!("查询用户失败: {}", err))?;
if let Some(user) = user {
let mut active_model = user.into_active_model();
active_model.last_opt_time = Set(Some(Local::now().timestamp_millis()));
ImUserEntity::update(active_model)
.exec(db.deref())
.await
.map_err(|err| format!("更新用户最后操作时间失败: {}", err))?;
}
Ok(())
}
+92
View File
@@ -0,0 +1,92 @@
use crate::error::CommonError;
use anyhow::Context;
use sea_orm::{Database, DatabaseConnection};
use std::path::PathBuf;
#[derive(serde::Deserialize, Clone, Debug)]
pub struct Settings {
pub database: DatabaseSettings,
pub backend: BackendSettings,
}
#[derive(serde::Deserialize, Clone, Debug)]
pub struct DatabaseSettings {
pub sqlite_file: String,
}
impl DatabaseSettings {
pub async fn connection_string(&self) -> Result<DatabaseConnection, CommonError> {
let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); // 项目根目录
path.push("db.sqlite");
let db_url = format!("sqlite:{}?mode=rwc", path.display());
let db: DatabaseConnection = Database::connect(db_url)
.await
.with_context(|| "连接数据库异常")?;
Ok(db)
}
}
#[derive(serde::Deserialize, Clone, Debug)]
pub struct BackendSettings {
pub base_url: String,
}
pub fn get_configuration() -> Result<Settings, config::ConfigError> {
let base_path = std::env::current_dir().expect("无法获取当前项目目录");
let configuration_directory = base_path.join("configuration");
// Detect the running environment.
// Default to `local` if unspecified.
let environment: Environment = std::env::var("APP_ENVIRONMENT")
.unwrap_or_else(|_| "local".into())
.try_into()
.expect("解析APP_ENVIRONMENT失败");
let environment_filename = format!("{}.yaml", environment.as_str());
let settings = config::Config::builder()
.add_source(config::File::from(
configuration_directory.join("base.yaml"),
))
.add_source(config::File::from(
configuration_directory.join(environment_filename),
))
.add_source(
config::Environment::with_prefix("APP")
.prefix_separator("_")
.separator("__"),
)
.build()?;
settings.try_deserialize::<Settings>()
}
/// The possible runtime environment for our application.
#[derive(Debug)]
pub enum Environment {
Local,
Production,
}
impl Environment {
pub fn as_str(&self) -> &'static str {
match self {
Environment::Local => "local",
Environment::Production => "production",
}
}
}
impl TryFrom<String> for Environment {
type Error = String;
fn try_from(s: String) -> Result<Self, Self::Error> {
match s.to_lowercase().as_str() {
"local" => Ok(Self::Local),
"production" => Ok(Self::Production),
other => Err(format!(
"{} is not a supported environment. Use either `local` or `production`.",
other
)),
}
}
}
+1 -1
View File
@@ -15,4 +15,4 @@ pub fn handle_app_event<R: Runtime>(app_handle: &AppHandle<R>, event: RunEvent)
}
_ => {}
}
}
}
+1 -10
View File
@@ -6,9 +6,7 @@ use screenshots::Screen;
use serde::Serialize;
use std::cmp;
use std::path::PathBuf;
use std::sync::{
Arc, RwLock,
};
use std::sync::{Arc, RwLock};
use std::thread;
use std::time::Duration;
use tauri::path::BaseDirectory;
@@ -23,7 +21,6 @@ pub struct DiskInfo {
usage_percentage: f64,
}
#[cfg(target_os = "macos")]
#[allow(deprecated)]
use cocoa::appkit::NSWindow;
@@ -283,9 +280,3 @@ pub async fn get_files_meta(files_path: Vec<String>) -> Result<Vec<FileMeta>, St
Ok(files_meta)
}
+19 -16
View File
@@ -52,22 +52,25 @@ async fn get_directory_size_with_progress(
// 创建取消接收器
let mut cancel_receiver = CANCEL_SENDER.subscribe();
let start_time = Instant::now();
// 1.快速预扫描计算总文件数
let mut total_files = 0u64;
let mut entries = AsyncWalkDir::new(&path);
let _ = handle.emit("directory-scan-progress", &DirectoryScanProgress {
current_path: "正在统计文件数量...".to_string(),
files_processed: 0,
total_size: 0,
elapsed_time: 0,
elapsed_seconds: 0.0,
progress_percentage: 0.0,
});
let _ = handle.emit(
"directory-scan-progress",
&DirectoryScanProgress {
current_path: "正在统计文件数量...".to_string(),
files_processed: 0,
total_size: 0,
elapsed_time: 0,
elapsed_seconds: 0.0,
progress_percentage: 0.0,
},
);
loop {
tokio::select! {
// 检查取消信号
@@ -89,12 +92,12 @@ async fn get_directory_size_with_progress(
}
}
}
// 2.实际扫描并计算准确进度
let mut total_size = 0u64;
let mut files_processed = 0u64;
let mut last_progress_time = Instant::now();
let mut entries = AsyncWalkDir::new(&path);
loop {
@@ -133,7 +136,7 @@ async fn get_directory_size_with_progress(
} else {
0.0
};
let progress = DirectoryScanProgress {
current_path: current_path.clone(),
files_processed,
@@ -244,4 +247,4 @@ pub async fn get_directory_usage_info_with_progress(
disk_usage_percentage,
usage_percentage,
})
}
}
+55 -1
View File
@@ -1,5 +1,11 @@
use std::path::PathBuf;
use tauri::plugin::TauriPlugin;
use tauri::{Manager, Runtime, WindowEvent};
use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_log::fern::colors::{Color, ColoredLevelConfig};
use tauri_plugin_log::{Target, TargetKind, WEBVIEW_TARGET};
pub trait CustomInit {
fn init_plugin(self) -> Self;
@@ -11,7 +17,7 @@ pub trait CustomInit {
impl<R: Runtime> CustomInit for tauri::Builder<R> {
// 初始化插件
fn init_plugin(self) -> Self {
self.plugin(tauri_plugin_os::init())
let mut builder = self.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_http::init())
@@ -41,6 +47,14 @@ impl<R: Runtime> CustomInit for tauri::Builder<R> {
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_mic_recorder::init())
.plugin(build_log_plugin());
#[cfg(debug_assertions)]
{
builder = builder.plugin(tauri_plugin_devtools::init());
}
builder
}
// 初始化web窗口事件
@@ -100,3 +114,43 @@ impl<R: Runtime> CustomInit for tauri::Builder<R> {
})
}
}
fn build_log_plugin<R: Runtime>() -> TauriPlugin<R> {
// 获取当前工作目录并创建 logs 子目录
let mut log_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
log_dir.push("logs");
// 确保 logs 目录存在
if !log_dir.exists() {
std::fs::create_dir_all(&log_dir).unwrap_or_else(|e| {
eprintln!("Failed to create logs directory: {}", e);
});
}
tauri_plugin_log::Builder::new()
.timezone_strategy(tauri_plugin_log::TimezoneStrategy::UseLocal)
.skip_logger()
.level(log::LevelFilter::Info)
.level_for("sqlx::query", log::LevelFilter::Warn)
.level_for("hula_app_lib", log::LevelFilter::Debug)
.targets([
Target::new(TargetKind::Stdout),
// 将 rust 日志打印到 webview的 devtool 中
Target::new(TargetKind::Webview),
// 将日志保存到项目的 logs 目录下
Target::new(TargetKind::Folder {
path: log_dir,
file_name: Some("app".into()),
})
.filter(|metadata| !metadata.target().starts_with(WEBVIEW_TARGET)),
])
.with_colors(ColoredLevelConfig {
error: Color::Red,
warn: Color::Yellow,
debug: Color::White,
info: Color::Green,
trace: Color::White,
})
.build()
}
+2 -2
View File
@@ -40,8 +40,8 @@ pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<()> {
let _ = TrayIconBuilder::with_id("tray")
.tooltip("HuLa")
.icon(app.default_window_icon().unwrap().clone())
.menu(&tray_menu) // 直接设置菜单,让系统处理右键显示
.show_menu_on_left_click(false) // 禁用左键显示菜单
.menu(&tray_menu) // 直接设置菜单,让系统处理右键显示
.show_menu_on_left_click(false) // 禁用左键显示菜单
.on_menu_event(move |app, event| {
let id = event.id();
if id == &open_id {
+34
View File
@@ -0,0 +1,34 @@
#[derive(thiserror::Error)]
pub enum CommonError {
#[error(transparent)]
UnexpectedError(#[from] anyhow::Error),
#[error("Database error: {0}")]
DatabaseError(#[from] sea_orm::DbErr),
#[error("token过期")]
TokenExpired,
}
impl From<CommonError> for String {
fn from(err: CommonError) -> String {
err.to_string()
}
}
impl std::fmt::Debug for CommonError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
error_chain_fmt(self, f)
}
}
pub fn error_chain_fmt(
e: &impl std::error::Error,
f: &mut std::fmt::Formatter<'_>,
) -> std::fmt::Result {
writeln!(f, "{}\n", e)?;
let mut current = e.source();
while let Some(cause) = current {
writeln!(f, "Caused by:\n\t{}", cause)?;
current = cause.source();
}
Ok(())
}
+418
View File
@@ -0,0 +1,418 @@
use crate::error::CommonError;
use crate::pojo::common::ApiResult;
use anyhow::Context;
use log::{debug, error, info, warn};
use reqwest::{Client, Method, RequestBuilder, header};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Emitter};
/// 智能 HTTP 客户端,支持自动 token 管理和过期重试
pub struct ImRequestClient {
client: Client,
base_url: String,
pub token: Arc<Mutex<Option<String>>>,
pub refresh_token: Arc<Mutex<Option<String>>>,
app_handle: Arc<Mutex<Option<AppHandle>>>,
}
impl ImRequestClient {
/// 创建新的请求客户端
pub async fn new(base_url: String) -> Result<Self, CommonError> {
let client = Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.with_context(|| "创建 HTTP 客户端失败")?;
Ok(Self {
client,
base_url,
token: Arc::new(Mutex::new(None)),
refresh_token: Arc::new(Mutex::new(None)),
app_handle: Arc::new(Mutex::new(None)),
})
}
/// 设置 AppHandle
pub fn set_app_handle(&self, app_handle: AppHandle) {
if let Ok(mut handle) = self.app_handle.lock() {
*handle = Some(app_handle);
}
}
/// 发送 GET 请求
pub fn get(&self, path: &str) -> RequestBuilderWrapper {
self.request(Method::GET, path)
}
/// 发送 POST 请求
pub fn post(&self, path: &str) -> RequestBuilderWrapper {
self.request(Method::POST, path)
}
/// 发送 PUT 请求
pub fn put(&self, path: &str) -> RequestBuilderWrapper {
self.request(Method::PUT, path)
}
/// 发送 DELETE 请求
pub fn delete(&self, path: &str) -> RequestBuilderWrapper {
self.request(Method::DELETE, path)
}
/// 创建请求构建器
fn request(&self, method: Method, path: &str) -> RequestBuilderWrapper {
let url = if path.starts_with("http") {
path.to_string()
} else {
format!(
"{}/{}",
self.base_url.trim_end_matches('/'),
path.trim_start_matches('/')
)
};
let request_builder = self.client.request(method.clone(), &url);
// 注意:这里不能直接获取 token,因为这是同步方法
// token 将在发送请求时异步获取
RequestBuilderWrapper {
request_builder,
client: self,
url,
method,
json_body: None,
headers: Vec::new(),
query_params: None,
}
}
/// 刷新 token
pub async fn refresh_token(&self) -> Result<(), CommonError> {
let current_refresh_token = {
let refresh_token_guard = self
.refresh_token
.lock()
.expect("获取 refresh_token 锁失败");
refresh_token_guard.clone()
};
info!("开始刷新 token...");
// 构建刷新 token 的请求
let refresh_url = format!("{}/token/refreshToken", self.base_url);
let request_builder = self.client.request(Method::POST, &refresh_url);
// 发送请求
let response = request_builder
.json(&HashMap::from([(
"refreshToken",
current_refresh_token.unwrap(),
)]))
.send()
.await
.with_context(|| "刷新 token 请求失败")?;
let response_text = response
.text()
.await
.with_context(|| "读取刷新 token 响应体失败")?;
// 解析响应
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct RefreshTokenResponse {
token: String,
refresh_token: String,
}
let result: ApiResult<RefreshTokenResponse> = match serde_json::from_str(&response_text) {
Ok(result) => result,
Err(e) => {
error!("刷新 token JSON 解析错误: {}", e);
error!("响应内容: {}", response_text);
return Err(CommonError::UnexpectedError(anyhow::anyhow!(
"解析刷新 token 响应失败: {}",
e
)));
}
};
if !result.success {
error!(
"刷新 token 失败: {}",
result.msg.clone().unwrap_or_default()
);
return Err(CommonError::UnexpectedError(anyhow::anyhow!(
"刷新 token 失败!"
)));
}
// 更新 token 和 refresh_token
if let Some(data) = result.data {
info!("token 刷新成功");
// 更新 token
if let Ok(mut token_guard) = self.token.lock() {
*token_guard = Some(data.token);
}
// 更新 refresh_token
if let Ok(mut refresh_token_guard) = self.refresh_token.lock() {
*refresh_token_guard = Some(data.refresh_token);
}
Ok(())
} else {
error!("刷新 token 响应数据为空");
Err(CommonError::UnexpectedError(anyhow::anyhow!(
"刷新 token 响应数据为空"
)))
}
}
}
/// 请求构建器包装器,支持自动重试
pub struct RequestBuilderWrapper<'a> {
request_builder: RequestBuilder,
client: &'a ImRequestClient,
url: String,
method: Method,
json_body: Option<String>,
headers: Vec<(String, String)>,
query_params: Option<String>,
}
impl<'a> RequestBuilderWrapper<'a> {
/// 添加查询参数
pub fn query<T: serde::Serialize + ?Sized>(mut self, query: &T) -> Self {
// 序列化查询参数以便重试时使用和日志记录
if let Ok(query_string) = serde_json::to_string(query) {
self.query_params = Some(query_string);
}
self.request_builder = self.request_builder.query(query);
self
}
/// 添加 JSON 请求体
pub fn json<T: serde::Serialize + ?Sized>(mut self, json: &T) -> Self {
// 序列化 JSON 体以便重试时使用
if let Ok(json_string) = serde_json::to_string(json) {
self.json_body = Some(json_string);
}
self.request_builder = self.request_builder.json(json);
self
}
/// 添加请求头
pub fn header<K, V>(mut self, key: K, value: V) -> Self
where
header::HeaderName: TryFrom<K>,
<header::HeaderName as TryFrom<K>>::Error: Into<http::Error>,
header::HeaderValue: TryFrom<V>,
<header::HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
K: Clone,
V: Clone,
{
// 记录请求头以便重试时使用
if let (Ok(header_name), Ok(header_value)) = (
header::HeaderName::try_from(key.clone()).map_err(|e| e.into()),
header::HeaderValue::try_from(value.clone()).map_err(|e| e.into()),
) {
self.headers.push((
header_name.to_string(),
header_value.to_str().unwrap_or_default().to_string(),
));
}
self.request_builder = self.request_builder.header(key, value);
self
}
/// 发送请求并解析 JSON 响应,支持自动 token 刷新
pub async fn send_json<T: serde::de::DeserializeOwned>(
mut self,
) -> Result<ApiResult<T>, CommonError> {
// 记录请求信息
let mut log_message = format!("发送请求到: {} [{}]", self.url, self.method);
// 添加查询参数信息
if let Some(ref query_params) = self.query_params {
log_message.push_str(&format!(" 查询参数: {}", query_params));
}
// 添加请求体信息
if let Some(ref json_body) = self.json_body {
log_message.push_str(&format!(" 请求体: {}", json_body));
}
debug!("{}", log_message);
// 获取 token 并添加到请求头
let current_token = {
if let Ok(token_guard) = self.client.token.lock() {
token_guard.clone()
} else {
warn!("获取 token 锁失败");
None
}
};
if let Some(token) = &current_token {
self.request_builder = self
.request_builder
.header(header::AUTHORIZATION, format!("Bearer {}", token));
} else {
warn!("没有设置 token");
}
let response = self
.request_builder
.send()
.await
.with_context(|| format!("[{}:{}] 发送请求失败: {}", file!(), line!(), self.url))?;
let response_text = response
.text()
.await
.with_context(|| format!("[{}:{}] 读取响应体失败", file!(), line!()))?;
debug!("开始解析响应");
// 解析为目标类型
let result: ApiResult<T> = match serde_json::from_str(&response_text) {
Ok(result) => result,
Err(e) => {
error!("JSON 解析错误: {}", e);
// error!("响应内容: {}", response_text);
return Err(CommonError::UnexpectedError(anyhow::anyhow!(
"解析 JSON 失败: {}",
e
)));
}
};
// token 过期,尝试刷新 token 并重试
if result.code == Some(40004) {
info!("检测到 token 过期,尝试刷新 token");
// 尝试刷新 token
match self.client.refresh_token().await {
Ok(()) => {
info!("token 刷新成功,重新发送请求");
// 重新构建请求(因为原来的 request_builder 已经被消费了)
let mut new_request_builder =
self.client.client.request(self.method.clone(), &self.url);
// 添加保存的 JSON 体
if let Some(json_body) = &self.json_body {
new_request_builder = new_request_builder
.header(header::CONTENT_TYPE, "application/json")
.body(json_body.clone());
}
// 添加保存的请求头
for (key, value) in &self.headers {
new_request_builder = new_request_builder.header(key, value);
}
// 获取新的 token
let new_token = {
if let Ok(token_guard) = self.client.token.lock() {
token_guard.clone()
} else {
None
}
};
if let Some(token) = &new_token {
new_request_builder = new_request_builder
.header(header::AUTHORIZATION, format!("Bearer {}", token));
}
// 重新发送请求
info!("重试请求到: {}", self.url);
let retry_response = new_request_builder.send().await.with_context(|| {
format!("[{}:{}] 重试请求失败: {}", file!(), line!(), self.url)
})?;
let retry_response_text = retry_response
.text()
.await
.with_context(|| format!("[{}:{}] 读取重试响应体失败", file!(), line!()))?;
// 解析重试响应
let retry_result: ApiResult<T> =
match serde_json::from_str(&retry_response_text) {
Ok(result) => result,
Err(e) => {
error!("重试请求 JSON 解析错误: {}", e);
// error!("响应内容: {}", retry_response_text);
return Err(CommonError::UnexpectedError(anyhow::anyhow!(
"解析重试请求 JSON 失败: {}",
e
)));
}
};
if !&retry_result.success {
error!(
"重试请求失败: {}",
&retry_result.msg.clone().unwrap_or_default()
);
return Err(CommonError::UnexpectedError(anyhow::anyhow!(
"重试请求失败!"
)));
}
debug!("重试请求成功");
// 发送 token_expired 事件到前端,包含当前的 token 和 refreshToken
if let Ok(app_handle_guard) = self.client.app_handle.lock() {
if let Some(app_handle) = app_handle_guard.as_ref() {
let token_info = {
let current_token =
if let Ok(token_guard) = self.client.token.lock() {
token_guard.clone()
} else {
warn!("获取 token 锁失败");
None
};
let current_refresh_token = if let Ok(refresh_token_guard) =
self.client.refresh_token.lock()
{
refresh_token_guard.clone()
} else {
warn!("获取 refresh_token 锁失败");
None
};
serde_json::json!({
"token": current_token,
"refreshToken": current_refresh_token
})
};
if let Err(emit_err) =
app_handle.emit("refresh_token_event", token_info)
{
error!("发送 refresh_token 事件失败: {:?}", emit_err);
}
}
}
return Ok(retry_result);
}
Err(e) => {
error!("刷新 token 失败: {:?}", e);
return Err(CommonError::TokenExpired);
}
}
}
if !&result.success {
error!("请求失败: {}", &result.msg.clone().unwrap_or_default());
return Err(CommonError::UnexpectedError(anyhow::anyhow!("请求失败!")));
}
debug!("解析完成");
Ok(result)
}
}
+159 -5
View File
@@ -16,19 +16,54 @@ use desktops::{common_cmd, directory_scanner, init, tray, video_thumbnail::get_v
use directory_scanner::{cancel_directory_scan, get_directory_usage_info_with_progress};
#[cfg(desktop)]
use init::CustomInit;
use moka::future::Cache;
use std::sync::Arc;
use std::time::Duration;
pub mod command;
pub mod configuration;
pub mod error;
pub mod im_reqest_client;
pub mod pojo;
pub mod repository;
mod vo;
use crate::command::room_member_command::{
cursor_page_room_members, get_room_members, page_room, update_my_room_info,
};
use crate::configuration::get_configuration;
use crate::error::CommonError;
use crate::im_reqest_client::ImRequestClient;
use anyhow::Context;
use sea_orm::DatabaseConnection;
use serde::{Deserialize, Serialize};
use std::ops::Deref;
// 移动端依赖
#[cfg(mobile)]
mod mobiles;
#[cfg(mobile)]
use init::CustomInit;
#[cfg(mobile)]
use mobiles::init;
#[cfg(mobile)]
mod mobiles;
pub fn run() {
pub struct AppData {
db_conn: Arc<DatabaseConnection>,
request_client: Arc<Mutex<ImRequestClient>>,
user_info: Arc<Mutex<UserInfo>>,
cache: Cache<String, String>,
}
use crate::command::contact_command::list_contacts_command;
use crate::command::message_command::{
check_user_init_and_fetch_messages, page_msg, save_msg, send_msg,
};
use tauri::Listener;
use tokio::sync::Mutex;
pub async fn run() {
#[cfg(desktop)]
{
setup_desktop();
setup_desktop().await.unwrap();
}
#[cfg(mobile)]
{
@@ -37,12 +72,57 @@ pub fn run() {
}
#[cfg(desktop)]
fn setup_desktop() {
async fn setup_desktop() -> Result<(), CommonError> {
use log::info;
use migration::{Migrator, MigratorTrait};
use crate::command::user_command::{save_user_info, update_user_last_opt_time};
let configuration = Arc::new(get_configuration().expect("加载配置文件失败"));
let db = Arc::new(configuration.database.connection_string().await?);
// 数据库迁移
Migrator::up(db.as_ref(), None).await?;
info!("数据库迁移完成");
let im_request_client =
ImRequestClient::new(configuration.clone().backend.base_url.clone()).await?;
let user_info = UserInfo {
token: Default::default(),
refresh_token: Default::default(),
uid: Default::default(),
};
// 创建一个缓存实例
let cache: Cache<String, String> = Cache::builder()
// Time to idle (TTI): 30 minutes
.time_to_idle(Duration::from_secs(30 * 60))
// Create the cache.
.build();
let client = Arc::new(Mutex::new(im_request_client));
let user_info = Arc::new(Mutex::new(user_info));
tauri::Builder::default()
.init_plugin()
.init_webwindow_event()
.init_window_event()
.manage(AppData {
db_conn: db.clone(),
request_client: client.clone(),
user_info: user_info.clone(),
cache,
})
.setup(move |app| {
// 设置 AppHandle 到 ImRequestClient(使用 spawn 避免运行时嵌套)
let client_clone = client.clone();
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
let client_guard = client_clone.lock().await;
client_guard.set_app_handle(app_handle);
});
// 监听前端事件,保存登录用户信息
setup_user_info_listener(app, client.clone(), user_info.clone(), db.clone());
tray::create_tray(app.handle())?;
Ok(())
})
@@ -55,6 +135,16 @@ fn setup_desktop() {
get_video_thumbnail,
#[cfg(target_os = "macos")]
hide_title_bar_buttons,
save_user_info,
update_user_last_opt_time,
page_room,
get_room_members,
update_my_room_info,
cursor_page_room_members,
list_contacts_command,
page_msg,
send_msg,
save_msg,
push_window_payload,
get_window_payload,
get_files_meta,
@@ -71,6 +161,70 @@ fn setup_desktop() {
let _ = (app_handle, event);
}
});
Ok(())
}
/// 设置用户信息事件监听器
fn setup_user_info_listener(
app: &tauri::App,
client: Arc<Mutex<ImRequestClient>>,
user_info: Arc<Mutex<UserInfo>>,
db_conn: Arc<DatabaseConnection>,
) {
app.listen("set_user_info", {
let client = client.clone();
let user_info = user_info.clone();
let db_conn = db_conn.clone();
move |event| {
let client = client.clone();
let user_info = user_info.clone();
let db_conn = db_conn.clone();
tauri::async_runtime::spawn(async move {
if let Ok(payload) = serde_json::from_str::<UserInfo>(&event.payload()) {
let client = client.lock().await;
// 更新 client 的 token
if let Ok(mut token_guard) = client.token.lock() {
*token_guard = Some(payload.token.clone());
}
if let Ok(mut refresh_token_guard) = client.refresh_token.lock() {
*refresh_token_guard = Some(payload.refresh_token.clone());
}
// 更新 client 的 refresh_token(如果有的话)
// 这里假设 UserInfo 可能包含 refresh_token,如果没有则保持原值
let mut user_info = user_info.lock().await;
user_info.uid = payload.uid.clone();
user_info.token = payload.token.clone();
user_info.refresh_token = payload.refresh_token.clone();
// 检查用户的 is_init 状态并获取消息
if let Err(e) =
check_user_init_and_fetch_messages(&client, db_conn.deref(), &payload.uid)
.await
{
log::error!("检查用户初始化状态并获取消息失败: {}", e);
}
}
});
}
});
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserInfo {
pub token: String,
pub refresh_token: String,
pub uid: String,
}
pub async fn build_request_client() -> Result<reqwest::Client, CommonError> {
let client = reqwest::Client::builder()
.build()
.with_context(|| "Reqwest client 异常")?;
Ok(client)
}
#[cfg(mobile)]
+4 -2
View File
@@ -1,6 +1,8 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
hula_app_lib::run()
#[tokio::main]
async fn main() -> std::io::Result<()> {
hula_app_lib::run().await;
Ok(())
}
+1 -1
View File
@@ -1 +1 @@
pub mod init;
pub mod init;
+63
View File
@@ -0,0 +1,63 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct PageParam {
pub current: u32,
pub size: u32,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct CursorPageParam {
pub page_size: u32,
pub cursor: String,
pub create_id: Option<String>,
pub create_time: Option<i64>,
pub update_time: Option<i64>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct CursorPageResp<T> {
pub cursor: String,
pub is_last: bool,
pub list: Option<T>,
pub total: u64,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct LoginParam {
pub account: String,
pub password: String,
pub source: String,
}
#[derive(Deserialize, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ApiResult<T> {
pub success: bool,
pub code: Option<i32>,
pub msg: Option<String>,
pub tid: String,
pub version: String,
pub data: Option<T>,
}
#[derive(serde::Deserialize, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Page<T> {
pub records: Vec<T>,
pub total: String,
pub size: String,
}
#[derive(serde::Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct LoginResp {
pub uuid: Option<String>,
pub token: String,
pub refresh_token: String,
pub client: String,
}
+1
View File
@@ -0,0 +1 @@
pub mod common;
@@ -0,0 +1,121 @@
use crate::error::CommonError;
use anyhow::Context;
use entity::im_config;
use sea_orm::QueryFilter;
use sea_orm::{ActiveModelTrait, ColumnTrait, IntoActiveModel, Set};
use sea_orm::{DatabaseConnection, EntityTrait, TransactionTrait};
/// 获取配置列表
pub async fn list_config(
db: &DatabaseConnection,
login_uid: &str,
) -> Result<Vec<im_config::Model>, CommonError> {
let list = im_config::Entity::find()
.filter(im_config::Column::LoginUid.eq(login_uid))
.all(db)
.await?;
Ok(list)
}
/// 根据配置键获取配置值
pub async fn get_config_by_key(
db: &DatabaseConnection,
config_key: &str,
login_uid: &str,
) -> Result<Option<im_config::Model>, CommonError> {
let config = im_config::Entity::find()
.filter(im_config::Column::ConfigKey.eq(config_key))
.filter(im_config::Column::LoginUid.eq(login_uid))
.one(db)
.await
.with_context(|| "查询配置失败")?;
Ok(config)
}
/// 保存或更新配置
pub async fn save_or_update_config(
db: &DatabaseConnection,
config_key: &str,
config_value: Option<String>,
login_uid: &str,
) -> Result<(), CommonError> {
// 查找现有配置
let existing_config = get_config_by_key(db, config_key, login_uid).await?;
if let Some(config) = existing_config {
// 更新现有配置
let mut config_active = config.into_active_model();
config_active.config_value = Set(config_value);
config_active.update(db).await?;
} else {
// 创建新配置
let new_config = im_config::Model {
id: 0, // 自增主键
config_key: config_key.to_string(),
config_value,
login_uid: login_uid.to_string(),
};
let mut config_active = new_config.into_active_model();
config_active.id = sea_orm::NotSet;
config_active.insert(db).await?;
}
Ok(())
}
/// 批量保存配置
pub async fn save_config_batch(
db: &DatabaseConnection,
configs: Vec<im_config::Model>,
login_uid: &str,
) -> Result<(), CommonError> {
if configs.is_empty() {
return Ok(());
}
// 使用事务确保操作的原子性
let txn = db.begin().await?;
// 先删除当前用户的现有配置数据
im_config::Entity::delete_many()
.filter(im_config::Column::LoginUid.eq(login_uid))
.exec(&txn)
.await
.with_context(|| "删除现有配置数据失败")?;
// 批量插入新的配置数据
let active_models: Vec<im_config::ActiveModel> = configs
.into_iter()
.map(|mut config| {
config.login_uid = login_uid.to_string();
let mut active_model = config.into_active_model();
active_model.id = sea_orm::NotSet;
active_model
})
.collect();
if !active_models.is_empty() {
im_config::Entity::insert_many(active_models)
.exec(&txn)
.await
.with_context(|| "批量插入配置数据失败")?;
}
// 提交事务
txn.commit().await?;
Ok(())
}
/// 删除配置
pub async fn delete_config(
db: &DatabaseConnection,
config_key: &str,
login_uid: &str,
) -> Result<(), CommonError> {
im_config::Entity::delete_many()
.filter(im_config::Column::ConfigKey.eq(config_key))
.filter(im_config::Column::LoginUid.eq(login_uid))
.exec(db)
.await
.with_context(|| "删除配置失败")?;
Ok(())
}
@@ -0,0 +1,59 @@
use crate::error::CommonError;
use anyhow::Context;
use entity::im_contact;
use sea_orm::{
ColumnTrait, DatabaseConnection, EntityTrait, IntoActiveModel, QueryFilter, TransactionTrait,
};
pub async fn list_contact(
db: &DatabaseConnection,
login_uid: &str,
) -> Result<Vec<im_contact::Model>, CommonError> {
let list = im_contact::Entity::find()
.filter(im_contact::Column::LoginUid.eq(login_uid))
.all(db)
.await?;
Ok(list)
}
/// 批量保存会话数据到本地数据库
pub async fn save_contact_batch(
db: &DatabaseConnection,
contacts: Vec<im_contact::Model>,
login_uid: &str,
) -> Result<(), CommonError> {
if contacts.is_empty() {
return Ok(());
}
// 使用事务确保操作的原子性
let txn = db.begin().await?;
// 先删除当前用户的现有会话数据
im_contact::Entity::delete_many()
.filter(im_contact::Column::LoginUid.eq(login_uid))
.exec(&txn)
.await
.with_context(|| "删除现有会话数据失败")?;
// 批量插入新的会话数据
let active_models: Vec<im_contact::ActiveModel> = contacts
.into_iter()
.map(|mut contact| {
contact.login_uid = login_uid.to_string();
let active_model = contact.into_active_model();
active_model
})
.collect();
if !active_models.is_empty() {
im_contact::Entity::insert_many(active_models)
.exec(&txn)
.await
.with_context(|| "批量插入会话数据失败")?;
}
// 提交事务
txn.commit().await?;
Ok(())
}
@@ -0,0 +1,144 @@
use crate::error::CommonError;
use crate::pojo::common::{CursorPageParam, CursorPageResp};
use anyhow::Context;
use entity::im_message;
use log::{debug, info};
use sea_orm::{
ColumnTrait, ConnectionTrait, DatabaseConnection, DatabaseTransaction, EntityTrait,
IntoActiveModel, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set,
};
pub async fn save_all<C>(db: &C, messages: Vec<im_message::Model>) -> Result<(), CommonError>
where
C: ConnectionTrait,
{
// SQLite 的变量限制通常是 999,为了安全起见,我们设置批次大小为 100
// 每个消息大约有 10-15 个字段,所以 100 条消息大约使用 1000-1500 个变量
const BATCH_SIZE: usize = 100;
let active_models: Vec<im_message::ActiveModel> = messages
.into_iter()
.map(|message| {
let msg_active = message.into_active_model();
msg_active
})
.collect();
// 如果数据量小于批次大小,直接插入
if active_models.len() <= BATCH_SIZE {
if !active_models.is_empty() {
let count = active_models.len();
im_message::Entity::insert_many(active_models)
.exec(db)
.await
.with_context(|| "批量插入消息失败")?;
info!("消息插入完成,共 {} 条", count);
}
} else {
// 分批插入
for (batch_index, chunk) in active_models.chunks(BATCH_SIZE).enumerate() {
debug!(
"正在插入第 {} 批消息,共 {} 条",
batch_index + 1,
chunk.len()
);
im_message::Entity::insert_many(chunk.to_vec())
.exec(db)
.await
.with_context(|| format!("插入第 {} 批消息失败", batch_index + 1))?;
}
info!("所有消息批量插入完成,总计 {} 条", active_models.len());
}
Ok(())
}
/// 根据房间ID进行游标分页查询消息
pub async fn cursor_page_messages(
db: &DatabaseConnection,
room_id: String,
cursor_page_param: CursorPageParam,
login_uid: &str,
) -> Result<CursorPageResp<Vec<im_message::Model>>, CommonError> {
// 查询总数
let total = im_message::Entity::find()
.filter(im_message::Column::RoomId.eq(&room_id))
.filter(im_message::Column::LoginUid.eq(login_uid))
.count(db)
.await
.with_context(|| "查询消息总数失败")?;
let mut query = im_message::Entity::find()
.filter(im_message::Column::RoomId.eq(room_id))
.filter(im_message::Column::LoginUid.eq(login_uid))
.order_by_desc(im_message::Column::Id)
.limit(cursor_page_param.page_size as u64);
// 如果提供了游标,添加过滤条件
if !cursor_page_param.cursor.is_empty() {
// 使用游标值过滤,获取小于该ID的记录(因为是降序排列)
query = query.filter(im_message::Column::Id.lt(&cursor_page_param.cursor));
}
let messages = query.all(db).await.with_context(|| "查询消息列表失败")?;
// 生成下一页的游标
let next_cursor = if messages.len() < cursor_page_param.page_size as usize {
String::new() // 已经是最后一页
} else {
messages
.last()
.map(|msg| msg.id.clone())
.unwrap_or_default()
};
let is_last = messages.len() < cursor_page_param.page_size as usize;
Ok(CursorPageResp {
cursor: next_cursor,
is_last,
list: Some(messages),
total,
})
}
/// 保存单个消息到数据库
pub async fn save_message(
db: &DatabaseTransaction,
message: im_message::Model,
) -> Result<(), CommonError> {
let active_model = message.into_active_model();
im_message::Entity::insert(active_model).exec(db).await?;
Ok(())
}
/// 更新消息发送状态
pub async fn update_message_status(
db: &DatabaseConnection,
message_id: &str,
status: &str,
id: Option<String>,
login_uid: String,
) -> Result<(), CommonError> {
let mut active_model: im_message::ActiveModel =
im_message::Entity::find_by_id((message_id.to_string(), login_uid))
.one(db)
.await
.with_context(|| "查找消息失败")?
.ok_or_else(|| CommonError::UnexpectedError(anyhow::anyhow!("消息不存在")))?
.into_active_model();
active_model.send_status = Set(status.to_string());
if status == "success" {
active_model.id = Set(id.unwrap());
}
im_message::Entity::update_many()
.set(active_model)
.filter(im_message::Column::Id.eq(message_id))
.exec(db)
.await?;
Ok(())
}
@@ -0,0 +1,235 @@
use anyhow::Context;
use entity::{im_room, im_room_member};
use log::{debug, info};
use sea_orm::EntityTrait;
use sea_orm::IntoActiveModel;
use sea_orm::PaginatorTrait;
use sea_orm::QuerySelect;
use sea_orm::TransactionTrait;
use sea_orm::{ActiveModelTrait, Set};
use sea_orm::{ColumnTrait, DatabaseConnection, QueryFilter, QueryOrder};
use crate::pojo::common::{CursorPageParam, CursorPageResp};
use crate::{
error::CommonError,
pojo::common::{Page, PageParam},
};
pub async fn cursor_page_room_members(
db: &DatabaseConnection,
room_id: String,
cursor_page_param: CursorPageParam,
login_uid: &str,
) -> Result<CursorPageResp<Vec<im_room_member::Model>>, CommonError> {
// 查询总数
let total = im_room_member::Entity::find()
.filter(im_room_member::Column::RoomId.eq(&room_id))
.filter(im_room_member::Column::LoginUid.eq(login_uid))
.count(db)
.await
.with_context(|| "查询房间成员总数失败")?;
let mut query = im_room_member::Entity::find()
.filter(im_room_member::Column::RoomId.eq(room_id))
.filter(im_room_member::Column::LoginUid.eq(login_uid))
.order_by_desc(im_room_member::Column::LastOptTime)
.limit(cursor_page_param.page_size as u64);
// 如果提供了游标,解析游标值并添加过滤条件
if !cursor_page_param.cursor.is_empty() {
// 从 cursor 中根据'_'分割最后一个字符串转为 i64
let cursor_parts: Vec<&str> = cursor_page_param.cursor.split('_').collect();
if let Some(last_part) = cursor_parts.last() {
if let Ok(cursor_value) = last_part.parse::<i64>() {
// 使用游标值过滤,获取小于该值的记录(因为是降序排列)
query = query.filter(im_room_member::Column::LastOptTime.lt(cursor_value));
}
}
}
let members = query.all(db).await.with_context(|| "查询房间成员失败")?;
// 构建下一页游标和判断是否为最后一页
let (next_cursor, is_last) = if members.len() < cursor_page_param.page_size as usize {
// 如果返回的记录数少于请求的页面大小,说明是最后一页
(String::new(), true)
} else if let Some(last_member) = members.last() {
// 使用最后一条记录的 last_opt_time 构建下一页游标
let next_cursor = format!("{}", last_member.last_opt_time);
(next_cursor, false)
} else {
(String::new(), true)
};
Ok(CursorPageResp {
cursor: next_cursor,
is_last,
list: Some(members),
total,
})
}
pub async fn get_room_page(
page_param: PageParam,
db: &DatabaseConnection,
login_uid: &str,
) -> Result<Page<im_room::Model>, CommonError> {
// 计算偏移量
let offset = (page_param.current - 1) * page_param.size;
// 查询总数
let total = im_room::Entity::find()
.filter(im_room::Column::LoginUid.eq(login_uid))
.count(db)
.await
.with_context(|| "查询房间成员总数失败")?;
// 分页查询数据
let records = im_room::Entity::find()
.filter(im_room::Column::LoginUid.eq(login_uid))
.offset(offset as u64)
.limit(page_param.size as u64)
.all(db)
.await
.with_context(|| "分页查询房间成员失败")?;
Ok(Page {
records,
total: total.to_string(),
size: page_param.size.to_string(),
})
}
pub async fn save_room_batch(
db: &DatabaseConnection,
room_members: Vec<im_room::Model>,
login_uid: &str,
) -> Result<(), CommonError> {
// 使用事务确保批量操作的原子性
let txn = db.begin().await?;
for mut member in room_members {
// 设置 login_uid
member.login_uid = login_uid.to_string();
// 检查记录是否已存在
let existing = im_room::Entity::find()
.filter(im_room::Column::Id.eq(member.id.clone()))
.filter(im_room::Column::LoginUid.eq(member.login_uid.clone()))
.one(&txn)
.await?;
if existing.is_none() {
// 如果记录不存在,执行插入
let member_active = member.into_active_model();
member_active.insert(&txn).await?;
}
// 如果记录已存在,跳过插入
}
// 提交事务
txn.commit().await?;
Ok(())
}
pub async fn get_room_members_by_room_id(
room_id: &str,
db: &DatabaseConnection,
login_uid: &str,
) -> Result<Vec<im_room_member::Model>, CommonError> {
let members = im_room_member::Entity::find()
.filter(im_room_member::Column::RoomId.eq(room_id))
.filter(im_room_member::Column::LoginUid.eq(login_uid))
.all(db)
.await
.with_context(|| "查询房间成员失败")?;
Ok(members)
}
pub async fn save_room_member_batch(
db: &DatabaseConnection,
room_members: Vec<im_room_member::Model>,
room_id: i64,
login_uid: &str,
) -> Result<(), CommonError> {
// 使用事务确保操作的原子性
let txn = db.begin().await?;
// 根据room_id和login_uid查询现有数据
let existing_members = im_room_member::Entity::find()
.filter(im_room_member::Column::RoomId.eq(room_id.to_string()))
.filter(im_room_member::Column::LoginUid.eq(login_uid))
.all(&txn)
.await
.with_context(|| "查询房间成员失败")?;
if !existing_members.is_empty() {
// 如果有数据,则删除当前用户的现有数据
im_room_member::Entity::delete_many()
.filter(im_room_member::Column::RoomId.eq(room_id.to_string()))
.filter(im_room_member::Column::LoginUid.eq(login_uid))
.exec(&txn)
.await
.with_context(|| "删除房间成员失败")?;
}
// 保存新的room_members数据(批量插入)
if !room_members.is_empty() {
let active_models: Vec<im_room_member::ActiveModel> = room_members
.into_iter()
.map(|member| {
let mut member_active = member.into_active_model();
member_active.login_uid = Set(login_uid.to_string());
member_active.room_id = Set(Some(room_id.to_string()));
member_active
})
.collect();
im_room_member::Entity::insert_many(active_models)
.exec(&txn)
.await?;
}
// 提交事务
txn.commit().await?;
Ok(())
}
pub async fn update_my_room_info(
db: &DatabaseConnection,
my_name: &str,
room_id: &str,
uid: &str,
login_uid: &str,
) -> Result<(), CommonError> {
// 根据 room_id、uid 和 login_uid 查找房间成员记录
let member = im_room_member::Entity::find()
.filter(im_room_member::Column::RoomId.eq(room_id))
.filter(im_room_member::Column::Uid.eq(uid))
.filter(im_room_member::Column::LoginUid.eq(login_uid))
.one(db)
.await
.with_context(|| "查询房间成员失败")?;
if let Some(member) = member {
debug!("找到房间成员记录: {:?}", member);
// 如果找到记录,更新 nickname 字段
let mut member_active = member.into_active_model();
member_active.my_name = Set(Some(my_name.to_string()));
member_active
.update(db)
.await
.with_context(|| "更新房间成员昵称失败")?;
info!("更新成员房间成员信息成功");
Ok(())
} else {
// 如果没有找到记录,返回错误
Err(CommonError::UnexpectedError(anyhow::anyhow!(
"未找到指定的房间成员记录: room_id={}, uid={}",
room_id,
uid
)))
}
}
@@ -0,0 +1,32 @@
use crate::error::CommonError;
use entity::im_user;
use entity::prelude::ImUserEntity;
use log::{error, info};
use sea_orm::{ActiveValue::Set, ConnectionTrait, EntityTrait};
/// 更新用户的 is_init 状态
pub async fn update_user_init_status<C>(
db: &C,
login_uid: &str,
is_init: bool,
) -> Result<(), CommonError>
where
C: ConnectionTrait,
{
let user_update = im_user::ActiveModel {
id: Set(login_uid.to_string()),
is_init: Set(is_init),
..Default::default()
};
match ImUserEntity::update(user_update).exec(db).await {
Ok(_) => {
info!("用户 {} 的 is_init 状态已更新为 {}", login_uid, is_init);
Ok(())
}
Err(e) => {
error!("更新用户 is_init 状态失败: {:?}", e);
Err(e.into())
}
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod im_config_repository;
pub mod im_contact_repository;
pub mod im_message_repository;
pub mod im_room_member_repository;
pub mod im_user_repository;
+2
View File
@@ -0,0 +1,2 @@
pub mod user_info;
pub mod vo;
+19
View File
@@ -0,0 +1,19 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct UserInfoVO {
pub uid: String,
pub account: String,
pub email: String,
pub name: String,
pub avatar: String,
pub sex: Option<i32>,
pub user_state_id: Option<String>,
pub modify_name_chance: Option<u32>,
pub avatar_update_time: Option<i64>,
pub context: bool,
pub num: i32,
pub update_time: Option<i64>,
pub create_time: Option<i64>,
pub token: String,
pub client: String,
}
+22
View File
@@ -0,0 +1,22 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MyRoomInfoReq {
pub id: String,
// 我的群昵称
pub my_name: String,
// 群备注
pub remark: String,
}
#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ChatMessageReq {
pub id: String,
pub room_id: Option<String>,
pub msg_type: Option<u8>,
pub body: Option<serde_json::Value>,
pub skip: Option<bool>,
pub is_temp: Option<bool>,
pub is_push_message: Option<bool>,
}
+18 -2
View File
@@ -18,14 +18,15 @@ import { useStorage } from '@vueuse/core'
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
import { useMitt } from '@/hooks/useMitt.ts'
import { useWindow } from '@/hooks/useWindow.ts'
import { listen } from '@tauri-apps/api/event'
const appWindow = WebviewWindow.getCurrent()
const { createWebviewWindow } = useWindow()
const settingStore = useSettingStore()
const { themes, lockScreen, page } = storeToRefs(settingStore)
const { resetLoginState, logout } = useLogin()
const token = useStorage('TOKEN', null)
const refreshToken = useStorage('REFRESH_TOKEN', null)
const token = useStorage<string | null>('TOKEN', null)
const refreshToken = useStorage<string | null>('REFRESH_TOKEN', null)
/** 不需要锁屏的页面 */
const LockExclusion = new Set(['/login', '/tray', '/qrCode', '/about', '/onlineStatus'])
@@ -108,6 +109,21 @@ watch(
{ immediate: true }
)
listen('refresh_token_event', (event) => {
console.log('🔄 收到 refresh_token 事件')
// 从 event.payload 中获取 token 和 refreshToken
const payload: any = event.payload
if (payload.token) {
token.value = payload.token
}
if (payload.refreshToken) {
refreshToken.value = payload.refreshToken
}
})
onMounted(async () => {
// 判断是否是桌面端,桌面端需要调整样式
isDesktop.value && (await import('@/styles/scss/global/desktop.scss'))
+5 -2
View File
@@ -20,8 +20,8 @@
</template>
<script setup>
import { invoke } from '@tauri-apps/api/core'
import { writeImage } from '@tauri-apps/plugin-clipboard-manager'
import { invokeWithErrorHandler, ErrorType } from '@/utils/TauriInvokeHandler.ts'
import { useCanvasTool } from '@/hooks/useCanvasTool'
import { useTauriListener } from '@/hooks/useTauriListener'
@@ -112,7 +112,10 @@ async function initCanvas() {
height: `${canvasHeight}`
}
const screenshotData = await invoke('screenshot', config)
const screenshotData = await invokeWithErrorHandler('screenshot', config, {
errorMessage: '截图失败',
errorType: ErrorType.Client
})
if (imgCanvas.value && maskCanvas.value) {
imgCanvas.value.width = canvasWidth
+19 -11
View File
@@ -195,7 +195,7 @@
</div>
</template>
<script setup lang="ts">
import { RoomTypeEnum } from '@/enums'
import { RoomTypeEnum, TauriCommand } from '@/enums'
import { useBadgeInfo, useUserInfo } from '@/hooks/useCached.ts'
import { useCommon } from '@/hooks/useCommon.ts'
import { AvatarUtils } from '@/utils/AvatarUtils'
@@ -203,6 +203,9 @@ import apis from '@/services/apis.ts'
import { useWindow } from '@/hooks/useWindow'
import { useImageViewer } from '@/stores/imageViewer'
import type { UserItem } from '@/services/types'
import { useCachedStore } from '~/src/stores/cached'
import { invokeWithErrorHandler } from '@/utils/TauriInvokeHandler'
import { ErrorType } from '@/common/exception'
const { openMsgSession } = useCommon()
const { createWebviewWindow } = useWindow()
@@ -223,6 +226,7 @@ const remarkInputRef = useTemplateRef('remarkInputRef')
const isEditingNickname = ref(false)
const nicknameValue = ref('')
const nicknameInputRef = useTemplateRef('nicknameInputRef')
const cacheStore = useCachedStore()
watchEffect(() => {
if (content.type === RoomTypeEnum.SINGLE) {
@@ -255,7 +259,7 @@ const startEditRemark = () => {
// 处理群备注更新
const handleRemarkUpdate = async () => {
if (remarkValue.value !== item.value.remark) {
await apis.updateMyRoomInfo({
await cacheStore.updateMyRoomInfo({
id: item.value.roomId,
remark: remarkValue.value,
myName: item.value.myName || ''
@@ -278,10 +282,10 @@ const startEditNickname = () => {
// 处理本群昵称更新
const handleNicknameUpdate = async () => {
if (nicknameValue.value !== item.value.myName) {
await apis.updateMyRoomInfo({
await cacheStore.updateMyRoomInfo({
id: item.value.roomId,
myName: nicknameValue.value,
remark: item.value.remark || ''
remark: remarkValue.value,
myName: item.value.myName || ''
})
item.value.myName = nicknameValue.value
window.$message.success('本群昵称更新成功')
@@ -300,12 +304,16 @@ const handleCopy = (account: string) => {
// 获取群组详情和成员信息
const fetchGroupMembers = async (roomId: string) => {
try {
const params = {
roomId: roomId,
current: 1,
size: 10 // 获取前10个成员
}
const response = await apis.getGroupList(params)
const response: any = await invokeWithErrorHandler(
TauriCommand.GET_ROOM_MEMBERS,
{
roomId: roomId
},
{
customErrorMessage: '获取群成员失败',
errorType: ErrorType.Network
}
)
if (response && response.list) {
// 使用每个成员的uid获取详细信息
const memberDetails = response.list.map((member: UserItem) => {
@@ -139,8 +139,8 @@ import { getFilesMeta, getUserAbsoluteVideosDir } from '@/utils/PathUtil'
import { useUserStore } from '@/stores/user'
import { join } from '@tauri-apps/api/path'
const { id } = defineProps<{
id: SessionItem['id']
const { detailId } = defineProps<{
detailId: SessionItem['detailId']
}>()
const globalStore = useGlobalStore()
const contactStore = useContactStore()
@@ -175,7 +175,7 @@ const isSingleChat = computed(() => {
/** 是否是好友关系 */
const isFriend = computed(() => {
if (!isSingleChat.value) return true
return contactStore.contactsList.some((contact: ContactItem) => contact.uid === id)
return contactStore.contactsList.some((contact: ContactItem) => contact.uid === detailId)
})
// 监听emojiShow的变化,当emojiShow为true时关闭recentlyTip
+12 -10
View File
@@ -386,6 +386,7 @@ import AvatarCropper from '@/components/common/AvatarCropper.vue'
import { useAvatarUpload } from '@/hooks/useAvatarUpload'
import { useWindow } from '@/hooks/useWindow'
import { useGlobalStore } from '@/stores/global'
import { useCachedStore } from '~/src/stores/cached'
const appWindow = WebviewWindow.getCurrent()
const { activeItem } = defineProps<{
@@ -408,6 +409,7 @@ const modalShow = ref(false)
const sidebarShow = ref(false)
const showLoading = ref(true)
const isLoading = ref(false)
const cacheStore = useCachedStore()
// 群组详情数据
const groupDetail = ref({
myNickname: '', // 我在本群的昵称
@@ -471,14 +473,14 @@ const MIN_LOADING_TIME = 300 // 最小加载时间(毫秒)
const isOnline = computed(() => {
if (activeItem.type === RoomTypeEnum.GROUP) return false
const contact = contactStore.contactsList.find((item) => item.uid === activeItem.id)
const contact = contactStore.contactsList.find((item) => item.uid === activeItem.detailId)
return contact?.activeStatus === OnlineEnum.ONLINE
})
/** 是否还是好友 */
const shouldShowDeleteFriend = computed(() => {
if (activeItem.type === RoomTypeEnum.GROUP) return false
return contactStore.contactsList.some((item) => item.uid === activeItem.id)
return contactStore.contactsList.some((item) => item.uid === activeItem.detailId)
})
const groupUserList = computed(() => groupStore.userList)
const messageOptions = computed(() => chatStore.currentMessageOptions)
@@ -504,8 +506,8 @@ const currentUserStatus = computed(() => {
if (activeItem.type === RoomTypeEnum.GROUP) return null
// 使用 useUserInfo 获取用户信息
if (!activeItem.id) return null
const userInfo = useUserInfo(activeItem.id).value
if (!activeItem.detailId) return null
const userInfo = useUserInfo(activeItem.detailId).value
// 从状态列表中找到对应的状态
return userStatusStore.stateList.find((state: { id: string }) => state.id === userInfo.userStateId)
@@ -526,8 +528,8 @@ const statusTitle = computed(() => {
const currentUserAvatar = computed(() => {
if (activeItem.type === RoomTypeEnum.GROUP) {
return AvatarUtils.getAvatarUrl(activeItem.avatar)
} else if (activeItem.id) {
return AvatarUtils.getAvatarUrl(useUserInfo(activeItem.id).value.avatar || activeItem.avatar)
} else if (activeItem.detailId) {
return AvatarUtils.getAvatarUrl(useUserInfo(activeItem.detailId).value.avatar || activeItem.avatar)
}
return AvatarUtils.getAvatarUrl(activeItem.avatar)
})
@@ -632,7 +634,7 @@ const handleCreateGroupOrInvite = () => {
/** 处理创建群聊 */
const handleCreateGroup = () => {
useMitt.emit(MittEnum.CREATE_GROUP, activeItem.id)
useMitt.emit(MittEnum.CREATE_GROUP, activeItem.detailId)
}
/** 处理邀请进群 */
@@ -684,7 +686,7 @@ const saveGroupInfo = async () => {
// 只有当数据发生变化时才发送请求
if (nicknameChanged || remarkChanged) {
// 使用updateMyRoomInfo接口更新我在群里的昵称和群备注
await apis.updateMyRoomInfo({
await cacheStore.updateMyRoomInfo({
id: activeItem.roomId,
myName: groupDetail.value.myNickname,
remark: groupDetail.value.groupRemark
@@ -852,8 +854,8 @@ const handleDelete = (label: RoomActEnum) => {
}
const handleConfirm = () => {
if (optionsType.value === RoomActEnum.DELETE_FRIEND && activeItem.id) {
contactStore.onDeleteContact(activeItem.id).then(() => {
if (optionsType.value === RoomActEnum.DELETE_FRIEND && activeItem.detailId) {
contactStore.onDeleteContact(activeItem.detailId).then(() => {
modalShow.value = false
sidebarShow.value = false
window.$message.success('已删除好友')
+16 -61
View File
@@ -110,7 +110,7 @@
item-resizable
@scroll="handleScroll($event)"
:item-size="46"
:items="filteredUserList">
:items="groupStore.userList">
<template #default="{ item }">
<n-popover
:ref="(el) => (infoPopoverRefs[item.uid] = el)"
@@ -146,7 +146,9 @@
@error="userLoadedMap[item.uid] = true" />
</div>
<n-flex vertical :size="2" class="flex-1 truncate">
<p :title="item.name" class="text-12px truncate flex-1">{{ item.name }}</p>
<p :title="item.name" class="text-12px truncate flex-1">
{{ item.myName ? item.myName : item.name }}
</p>
<n-flex
v-if="item.userStateId && getUserState(item.userStateId)"
align="center"
@@ -161,12 +163,12 @@
</n-flex>
<div
v-if="item.roleId === RoleEnum.LORD"
v-if="item.groupRole === RoleEnum.LORD"
class="flex px-4px bg-#d5304f30 py-3px rounded-4px size-fit select-none">
<p class="text-(10px #d5304f)">群主</p>
</div>
<div
v-if="item.roleId === RoleEnum.ADMIN"
v-if="item.groupRole === RoleEnum.ADMIN"
class="flex px-4px bg-#1a7d6b30 py-3px rounded-4px size-fit select-none">
<p class="text-(10px #008080)">管理员</p>
</div>
@@ -189,7 +191,6 @@ import { useMitt } from '@/hooks/useMitt.ts'
import { useGroupStore } from '@/stores/group.ts'
import { useUserInfo } from '@/hooks/useCached.ts'
import { useGlobalStore } from '@/stores/global.ts'
import type { UserItem } from '@/services/types.ts'
import { useDebounceFn } from '@vueuse/core'
import { AvatarUtils } from '@/utils/AvatarUtils'
import { useCachedStore } from '@/stores/cached.ts'
@@ -216,42 +217,6 @@ const isLoadingMembers = ref(true)
const isLoadingAnnouncement = ref(true)
// 在线人数加载状态
const isLoadingOnlineCount = ref(true)
const groupUserList = computed(() => groupStore.userList)
const userList = computed(() => {
// 先获取所有需要的用户ID
const userIds = groupUserList.value.map((item) => item.uid)
// 确保所有用户信息都被加载
if (userIds.length > 0) {
cachedStore.getBatchUserInfo(userIds)
}
return groupUserList.value
.map((item: UserItem) => {
const cachedUser = useUserInfo(item.uid).value
// 合并数据时保留所有需要的字段
return {
...item, // 保留原始数据
...cachedUser, // 合并缓存的用户数据
account: cachedUser.account || item.account, // 确保accountCode被保留
uid: item.uid // 确保uid被保留
}
})
.sort((a, b) => {
// roleId === 1 的排在最前面
// 首先按照roleId排序
if (a.roleId !== b.roleId) {
// roleId === 2 的排在第二位
if (a.roleId === RoleEnum.LORD) return -1
if (b.roleId === RoleEnum.LORD) return 1
if (a.roleId === RoleEnum.ADMIN) return -1
if (b.roleId === RoleEnum.ADMIN) return 1
}
// roleId相同时,按照activeStatus升序排序
return a.activeStatus - b.activeStatus
})
})
const filteredUserList = shallowRef(userList.value)
const isGroup = computed(() => globalStore.currentSession?.type === RoomTypeEnum.GROUP)
/** 是否是搜索模式 */
const isSearch = ref(false)
@@ -265,11 +230,11 @@ const { handlePopoverUpdate, enableScroll } = usePopover(selectKey, 'image-chat-
provide('popoverControls', { enableScroll })
const isLord = computed(() => {
const currentUser = groupUserList.value.find((user) => user.uid === useUserStore().userInfo?.uid)
const currentUser = groupStore.userList.find((user) => user.uid === useUserStore().userInfo?.uid)
return currentUser?.roleId === RoleEnum.LORD
})
const isAdmin = computed(() => {
const currentUser = groupUserList.value.find((user) => user.uid === useUserStore().userInfo?.uid)
const currentUser = groupStore.userList.find((user) => user.uid === useUserStore().userInfo?.uid)
return currentUser?.roleId === RoleEnum.ADMIN
})
@@ -296,7 +261,7 @@ const mergedUserList = computed(() => {
const userMap = new Map()
// 首先添加在线用户列表
userList.value.forEach((user) => {
groupStore.userList.forEach((user) => {
userMap.set(user.uid, user)
})
@@ -318,19 +283,17 @@ const mergedUserList = computed(() => {
// 修改watch监听器
watch(
[userList, () => cachedStore.currentAtUsersList],
[() => groupStore.userList, () => cachedStore.currentAtUsersList],
() => {
// 如果正在搜索,则应用搜索过滤
if (searchRef.value) {
filteredUserList.value = mergedUserList.value.filter((user) =>
groupStore.userList = mergedUserList.value.filter((user) =>
user.name.toLowerCase().includes(searchRef.value.toLowerCase())
)
} else {
filteredUserList.value = userList.value
}
// 判断成员列表是否已加载完成
if (userList.value.length > 0 && currentLoadingRoomId.value === globalStore.currentSession?.roomId) {
if (groupStore.userList.length > 0 && currentLoadingRoomId.value === globalStore.currentSession?.roomId) {
isLoadingMembers.value = false
}
},
@@ -342,14 +305,8 @@ watch(
* @param value 输入值
*/
const handleSearch = useDebounceFn((value: string) => {
if (!value) {
// 如果搜索框为空,只显示在线用户列表
filteredUserList.value = userList.value
return
}
// 从合并后的用户列表中搜索
filteredUserList.value = mergedUserList.value.filter((user) => user.name.toLowerCase().includes(value.toLowerCase()))
groupStore.userList = mergedUserList.value.filter((user) => user.name.toLowerCase().includes(value.toLowerCase()))
}, 10)
/**
@@ -359,8 +316,6 @@ const handleBlur = () => {
if (searchRef.value) return
isSearch.value = false
searchRef.value = ''
// 重置为只显示在线用户列表
filteredUserList.value = userList.value
}
/**
@@ -481,7 +436,7 @@ onMounted(async () => {
currentLoadingRoomId.value = newSession.roomId
// 重置群组数据后再加载新的群成员数据
groupStore.resetGroupData()
await groupStore.getGroupUserList(true, newSession.roomId)
await groupStore.getGroupUserList()
// 获取群组统计信息(包括在线人数)
await groupStore.getCountStatistic()
isLoadingOnlineCount.value = false
@@ -494,8 +449,8 @@ onMounted(async () => {
)
// 初始化时获取当前群组用户的信息
if (groupUserList.value.length > 0) {
await cachedStore.getBatchUserInfo(groupUserList.value.map((item) => item.uid))
if (groupStore.userList.length > 0) {
await cachedStore.getBatchUserInfo(groupStore.userList.map((item) => item.uid))
const handleAnnounInitOnEvent = (shouldReload: boolean) => {
return async (event: any) => {
if (shouldReload || event) {
+30 -1
View File
@@ -6,7 +6,7 @@
<!-- 中间聊天框内容 -->
<ChatMain :active-item="activeItemRef" />
<!-- 输入框和操作列表 -->
<ChatFooter class="flex-1" :id="activeItemRef.id" />
<ChatFooter class="flex-1" :detail-id="activeItemRef.detailId" />
</n-flex>
<ChatSidebar />
</n-flex>
@@ -16,6 +16,9 @@ import type { SessionItem } from '@/services/types.ts'
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
import { useSettingStore } from '@/stores/setting.ts'
import { useTauriListener } from '@/hooks/useTauriListener'
import { listen } from '@tauri-apps/api/event'
import { useChatStore } from '~/src/stores/chat'
import { MessageStatusEnum } from '~/src/enums'
const { addListener } = useTauriListener()
const settingStore = useSettingStore()
@@ -24,6 +27,7 @@ const appWindow = WebviewWindow.getCurrent()
const { activeItem } = defineProps<{
activeItem?: SessionItem
}>()
const chatStore = useChatStore()
provide('activeItem', { ...activeItem! })
const activeItemRef = ref({ ...activeItem! })
@@ -31,11 +35,36 @@ watchEffect(() => {
activeItemRef.value = { ...activeItem! }
})
// 注册 Tauri 事件监听器
const unlistenSuccess = await listen('send_msg_success', async (event) => {
let msg = event.payload as any
chatStore.updateMsg({
msgId: msg.oldMsgId,
status: MessageStatusEnum.SUCCESS,
newMsgId: msg.message.id,
body: msg.message.body
})
})
const unlistenError = await listen('send_msg_error', (event) => {
let msgId = event.payload as any
chatStore.updateMsg({
msgId: msgId,
status: MessageStatusEnum.FAILED
})
})
addListener(
appWindow.listen(appWindow.label, (e: { payload: SessionItem }) => {
activeItemRef.value = e.payload
})
)
// 在组件卸载时清理监听器
onUnmounted(() => {
unlistenSuccess()
unlistenError()
})
</script>
<style scoped lang="scss">
/**! 修改naive-ui虚拟列表滚动条的间距 */
+24
View File
@@ -455,3 +455,27 @@ export enum NotificationTypeEnum {
/** 接收但不提醒[免打扰] */
NOT_DISTURB = 1
}
/** Tauri 命令 */
export enum TauriCommand {
/** 更新我的群聊信息 */
UPDATE_MY_ROOM_INFO = 'update_my_room_info',
/** 获取房间成员 */
GET_ROOM_MEMBERS = 'get_room_members',
/** 分页查询所有房间 */
PAGE_ROOM = 'page_room',
/** 分页查询房间成员 */
CURSOR_PAGE_ROOM_MEMBERS = 'cursor_page_room_members',
/** 列出所有会话列表 */
LIST_CONTACTS = 'list_contacts_command',
/** 分页查询会话消息 */
PAGE_MSG = 'page_msg',
/** 保存用户信息 */
SAVE_USER_INFO = 'save_user_info',
/** 更新用户最后操作时间 */
UPDATE_USER_LAST_OPT_TIME = 'update_user_last_opt_time',
/** 发送消息 */
SEND_MSG = 'send_msg',
/** 保存消息 */
SAVE_MSG = 'save_msg'
}
+4 -3
View File
@@ -1,9 +1,9 @@
import { emit } from '@tauri-apps/api/event'
import { EventEnum, RoomTypeEnum } from '@/enums'
import { EventEnum, RoomTypeEnum, TauriCommand } from '@/enums'
import { useWindow } from '@/hooks/useWindow.ts'
import { useGlobalStore } from '@/stores/global.ts'
import { type } from '@tauri-apps/plugin-os'
import { invoke } from '@tauri-apps/api/core'
import { invokeSilently } from '@/utils/TauriInvokeHandler.ts'
import { LoginStatus, useWsLoginStore } from '@/stores/ws'
import { useUserStore } from '@/stores/user'
import { useChatStore } from '@/stores/chat'
@@ -44,6 +44,7 @@ export const useLogin = () => {
await resizeWindow('tray', 130, 44)
// 发送登出事件
await emit(EventEnum.LOGOUT)
await invokeSilently(TauriCommand.UPDATE_USER_LAST_OPT_TIME)
} catch (error) {
console.error('创建登录窗口失败:', error)
}
@@ -71,7 +72,7 @@ export const useLogin = () => {
chatStore.clearUnreadCount()
// 5. 清除系统托盘图标上的未读数
try {
await invoke('set_badge_count', { count: null })
await invokeSilently('set_badge_count', { count: null })
} catch (error) {
console.error('清除系统托盘图标上的未读数失败:', error)
}
+89 -72
View File
@@ -1,6 +1,5 @@
import { LimitEnum, MittEnum, MsgEnum, MessageStatusEnum, RoomTypeEnum, UploadSceneEnum } from '@/enums'
import { LimitEnum, MittEnum, MsgEnum, MessageStatusEnum, RoomTypeEnum, UploadSceneEnum, TauriCommand } from '@/enums'
import { useUserInfo } from '@/hooks/useCached.ts'
import apis from '@/services/apis.ts'
import { useCachedStore, type BaseUserItem } from '@/stores/cached.ts'
import { useChatStore } from '@/stores/chat.ts'
import { useGlobalStore } from '@/stores/global.ts'
@@ -19,6 +18,8 @@ import type { AIModel } from '@/services/types.ts'
import { UploadProviderEnum, useUpload } from './useUpload.ts'
import { getReplyContent } from '@/utils/MessageReply.ts'
import { fixFileMimeType, getMessageTypeByFile } from '@/utils/FileType.ts'
import { invokeWithErrorHandler } from '@/utils/TauriInvokeHandler'
import { ErrorType } from '@/common/exception'
/**
* 光标管理器
*/
@@ -464,22 +465,31 @@ export const useMsgInput = (messageInputDom: Ref) => {
console.log('视频上传完成,更新为服务器URL:', messageBody.url)
}
// 发送消息到服务器
const res = await apis.sendMsg({
roomId: globalStore.currentSession.roomId,
msgType: msg.type,
body: messageBody
})
await invokeWithErrorHandler(
TauriCommand.SEND_MSG,
{
data: {
id: tempMsgId,
roomId: globalStore.currentSession.roomId,
msgType: msg.type,
body: messageBody
}
},
{
customErrorMessage: '消息发送失败',
errorType: ErrorType.Network
}
)
// 停止发送状态的定时器
clearTimeout(statusTimer)
// 更新消息状态为成功,并使用服务器返回的消息体
chatStore.updateMsg({
msgId: tempMsgId,
status: MessageStatusEnum.SUCCESS,
newMsgId: res.message.id,
body: res.message.body
})
// chatStore.updateMsg({
// msgId: tempMsgId,
// status: MessageStatusEnum.SUCCESS,
// newMsgId: res,
// })
// 更新会话最后活动时间
chatStore.updateSessionLastActiveTime(globalStore.currentSession.roomId)
@@ -934,31 +944,31 @@ export const useMsgInput = (messageInputDom: Ref) => {
thumbnailUploadResponse?.downloadUrl || `${qiniuConfig.domain}/${thumbnailUploadResponse?.key}`
// 发送消息到服务器保存
const serverResponse = await apis.sendMsg({
roomId: globalStore.currentSession.roomId,
msgType: MsgEnum.VIDEO,
body: {
url: finalVideoUrl,
size: processedFile.size,
fileName: processedFile.name,
thumbUrl: finalThumbnailUrl,
thumbWidth: 300,
thumbHeight: 150,
thumbSize: thumbnailFile.size,
localPath: videoPath, // 保存本地缓存路径
senderUid: userUid.value // 保存发送者UID
await invokeWithErrorHandler(
TauriCommand.SEND_MSG,
{
data: {
id: tempMsgId,
roomId: globalStore.currentSession.roomId,
msgType: MsgEnum.VIDEO,
body: {
url: finalVideoUrl,
size: processedFile.size,
fileName: processedFile.name,
thumbUrl: finalThumbnailUrl,
thumbWidth: 300,
thumbHeight: 150,
thumbSize: thumbnailFile.size,
localPath: videoPath, // 保存本地缓存路径
senderUid: userUid.value // 保存发送者UID
}
}
},
{
customErrorMessage: '视频消息发送失败',
errorType: ErrorType.Network
}
})
// 使用服务器返回的数据更新消息状态为SUCCESS,清除进度信息
chatStore.updateMsg({
msgId: tempMsgId,
status: MessageStatusEnum.SUCCESS,
newMsgId: serverResponse.message.id, // 使用服务器返回的消息ID
body: serverResponse.message.body, // 使用服务器返回的消息体
uploadProgress: undefined // 清除进度信息
})
)
// 清理本地URL
URL.revokeObjectURL(tempMsg.message.body.url)
URL.revokeObjectURL(localThumbUrl)
@@ -1002,19 +1012,21 @@ export const useMsgInput = (messageInputDom: Ref) => {
console.log('🖼️ 图片上传完成,更新为服务器URL:', messageBody.url)
// 发送消息到服务器
const serverResponse = await apis.sendMsg({
roomId: globalStore.currentSession.roomId,
msgType: MsgEnum.IMAGE,
body: messageBody
})
// 更新消息状态为成功,并使用服务器返回的消息体
chatStore.updateMsg({
msgId: tempMsgId,
status: MessageStatusEnum.SUCCESS,
newMsgId: serverResponse.message.id,
body: serverResponse.message.body
})
await invokeWithErrorHandler(
TauriCommand.SEND_MSG,
{
data: {
id: tempMsgId,
roomId: globalStore.currentSession.roomId,
msgType: MsgEnum.IMAGE,
body: messageBody
}
},
{
customErrorMessage: '图片消息发送失败',
errorType: ErrorType.Network
}
)
// 更新会话最后活动时间
chatStore.updateSessionLastActiveTime(globalStore.currentSession.roomId)
@@ -1094,24 +1106,26 @@ export const useMsgInput = (messageInputDom: Ref) => {
console.log('📎 文件上传完成,更新为服务器URL:', messageBody.url)
// 发送消息到服务器
const serverResponse = await apis.sendMsg({
roomId: globalStore.currentSession.roomId,
msgType: MsgEnum.FILE,
body: messageBody
})
// 更新消息状态为成功,并使用服务器返回的消息体
chatStore.updateMsg({
msgId: tempMsgId,
status: MessageStatusEnum.SUCCESS,
newMsgId: serverResponse.message.id,
body: serverResponse.message.body
})
await invokeWithErrorHandler(
TauriCommand.SEND_MSG,
{
data: {
id: tempMsgId,
roomId: globalStore.currentSession.roomId,
msgType: MsgEnum.FILE,
body: messageBody
}
},
{
customErrorMessage: '文件消息发送失败',
errorType: ErrorType.Network
}
)
// 更新会话最后活动时间
chatStore.updateSessionLastActiveTime(globalStore.currentSession.roomId)
console.log('📎 文件消息发送成功:', serverResponse.message.id)
// console.log('📎 文件消息发送成功:', serverResponse.message.id)
// 清理进度监听器
if (progressUnsubscribe) {
@@ -1227,22 +1241,25 @@ export const useMsgInput = (messageInputDom: Ref) => {
})
const sendData = {
id: tempMsgId,
roomId: globalStore.currentSession.roomId,
msgType: MsgEnum.VOICE,
body: messageBody
}
try {
const res = await apis.sendMsg(sendData)
await invokeWithErrorHandler(
TauriCommand.SEND_MSG,
{
data: sendData
},
{
customErrorMessage: '语音消息发送失败',
errorType: ErrorType.Network
}
)
// 停止发送状态的定时器
clearTimeout(statusTimer)
// 更新消息状态为成功,并使用服务器返回的消息体
chatStore.updateMsg({
msgId: tempMsgId,
status: MessageStatusEnum.SUCCESS,
newMsgId: res.message.id,
body: res.message.body
})
// 更新会话最后活动时间
chatStore.updateSessionLastActiveTime(globalStore.currentSession.roomId)
+1 -1
View File
@@ -149,7 +149,7 @@ export const useNetworkReconnect = () => {
}
// 如果当前是群聊,刷新群组信息
if (globalStore.currentSession?.type === RoomTypeEnum.GROUP) {
await groupStore.getGroupUserList(true)
await groupStore.getGroupUserList()
await groupStore.getCountStatistic()
await cachedStore.getGroupAtUserBaseInfo()
}
+14 -1
View File
@@ -27,7 +27,15 @@
<script setup lang="ts">
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import { useMitt } from '@/hooks/useMitt.ts'
import { ChangeTypeEnum, MittEnum, ModalEnum, NotificationTypeEnum, OnlineEnum, RoomTypeEnum } from '@/enums'
import {
ChangeTypeEnum,
MittEnum,
ModalEnum,
NotificationTypeEnum,
OnlineEnum,
RoomTypeEnum,
TauriCommand
} from '@/enums'
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
import { useGlobalStore } from '@/stores/global.ts'
import { useContactStore } from '@/stores/contacts.ts'
@@ -48,6 +56,7 @@ import { useConfigStore } from '@/stores/config'
import { useCheckUpdate } from '@/hooks/useCheckUpdate'
import { UserAttentionType } from '@tauri-apps/api/window'
import { LogicalSize } from '@tauri-apps/api/dpi'
import { invokeSilently } from '@/utils/TauriInvokeHandler'
const loadingPercentage = ref(10)
const loadingText = ref('正在加载应用...')
@@ -233,6 +242,10 @@ useMitt.on(WsResponseMessageType.MY_ROOM_INFO_CHANGE, (data: { myName: string; r
})
useMitt.on(WsResponseMessageType.RECEIVE_MESSAGE, async (data: MessageType) => {
chatStore.pushMsg(data)
console.log('监听到接收消息', data)
await invokeSilently(TauriCommand.SAVE_MSG, {
data
})
const username = useUserInfo(data.fromUser.uid).value.name!
const home = await WebviewWindow.getByLabel('home')
// 当home窗口不显示并且home窗口不是最小化的时候并且不是聚焦窗口的时候
+5 -9
View File
@@ -13,7 +13,6 @@ import {
ListResponse,
LoginUserReq,
MarkMsgReq,
MessageReq,
MessageType,
MsgReadUnReadCountType,
PageInfo,
@@ -21,7 +20,6 @@ import {
RequestFriendItem,
SessionItem,
UserInfoType,
UserItem,
UserState,
Login,
SearchFriend,
@@ -40,12 +38,10 @@ const PUT = <T>(url: string, params?: any, abort?: AbortController) => request.p
const DELETE = <T>(url: string, params?: any, abort?: AbortController) => request.delete<T>(url, params, abort)
export default {
/** 获取群成员列表 */
getGroupList: (params?: any) => GET<ListResponse<UserItem>>(urls.getGroupUserList, params),
/** 获取群成员统计 */
getMemberStatistic: () => GET<GroupStatisticType>(urls.getMemberStatistic),
/** 房间内的所有群成员列表-@专用 */
getAllUserBaseInfo: (params?: any) => GET<CacheUserItem[]>(urls.getAllUserBaseInfo, params),
// getAllUserBaseInfo: (params?: any) => GET<CacheUserItem[]>(urls.getAllUserBaseInfo, params),
/** 批量获取成员详细信息 */
getUserInfoBatch: (users: CacheUserReq[]) => POST<CacheUserItem[]>(urls.getUserInfoBatch, { reqList: users }),
/** 批量获取徽章信息 */
@@ -53,7 +49,7 @@ export default {
/** 获取消息列表 */
getMsgList: (params?: any) => GET<ListResponse<MessageType>>(urls.getMsgList, params),
/** 发送消息 */
sendMsg: (data?: MessageReq) => POST<MessageType>(urls.sendMsg, data),
// sendMsg: (data?: MessageReq) => POST<MessageType>(urls.sendMsg, data),
/** 标记消息,点赞等 */
markMsg: (data?: MarkMsgReq) => PUT<void>(urls.markMsg, data),
/** 获取用户详细信息 */
@@ -97,7 +93,7 @@ export default {
/** 好友申请未读数 */
newFriendCount: () => GET<{ unReadCount: number }>(urls.newFriendCount),
/** 会话列表 */
getSessionList: (params?: any) => GET<ListResponse<SessionItem>>(urls.getSessionList, params),
// getSessionList: (params?: any) => GET<ListResponse<SessionItem>>(urls.getSessionList, params),
/** 消息的已读未读列表 */
getMsgReadList: (params?: any) => GET<ListResponse<{ uid: string }>>(urls.getMsgReadList, params),
/** 消息已读未读数 */
@@ -138,8 +134,8 @@ export default {
/** 修改群信息(群主) */
updateRoomInfo: (params: { id: string; name: string; avatar: string }) => POST<void>(urls.updateRoomInfo, params),
/** 修改“我”的群聊名称 */
updateMyRoomInfo: (params: { id: string; myName: string; remark: string }) =>
POST<void>(urls.updateMyRoomInfo, params),
// updateMyRoomInfo: (params: { id: string; myName: string; remark: string }) =>
// POST<void>(urls.updateMyRoomInfo, params),
/** 添加群管理 */
addAdmin: ({ roomId, uidList }: { roomId: string; uidList: string[] }) =>
PUT<boolean>(urls.addAdmin, {
+2
View File
@@ -516,6 +516,8 @@ export type SessionItem = {
avatar: string
/** 如果是单聊,则是对方的uid,如果是群聊,则是群id */
id: string
/** 如果是单聊,则是对方的uid,如果是群聊,则是群id */
detailId: string
/** 是否全员展示的会话 0否 1是 */
hotFlag: IsAllUserEnum
/** 会话名称 */
+4 -4
View File
@@ -25,7 +25,7 @@ export default {
getBadgesBatch: `${prefix + URLEnum.USER}/badges/batch`,
getAllUserBaseInfo: `${prefix + URLEnum.ROOM}/group/member/list`, // 房间内的所有群成员列表-@专用
getMsgList: `${prefix + URLEnum.CHAT}/msg/page`,
sendMsg: `${prefix + URLEnum.CHAT}/msg`,
// sendMsg: `${prefix + URLEnum.CHAT}/msg`,
getUserInfoDetail: `${prefix + URLEnum.USER}/userInfo`, // 获取用户信息详情
modifyUserName: `${prefix + URLEnum.USER}/name`, // 修改用户名
setUserBadge: `${prefix + URLEnum.USER}/badge`, // 设置用户徽章
@@ -51,7 +51,7 @@ export default {
modifyFriendRemark: `${prefix + URLEnum.USER}/friend/updateRemark`, // 修改好友备注
// -------------- 聊天室相关 ---------------
getSessionList: `${prefix + URLEnum.CHAT}/contact/page`, // 会话列表
// getSessionList: `${prefix + URLEnum.CHAT}/contact/page`, // 会话列表
getMsgReadList: `${prefix + URLEnum.CHAT}/msg/read/page`, // 消息的已读未读列表
getMsgReadCount: `${prefix + URLEnum.CHAT}/msg/read`, // 消息已读未读数
sessionDetail: `${prefix + URLEnum.CHAT}/contact/detail`, // 会话详情
@@ -63,7 +63,7 @@ export default {
shield: `${prefix + URLEnum.CHAT}/setShield`, // 屏蔽消息
// -------------- 群聊相关 ---------------
createGroup: `${prefix + URLEnum.ROOM}/group`, // 新增群组
getGroupUserList: `${prefix + URLEnum.ROOM}/group/member/page`, // 群成员列表
// getGroupUserList: `${prefix + URLEnum.ROOM}/group/member/page`, // 群成员列表
inviteGroupMember: `${prefix + URLEnum.ROOM}/group/member`, // 邀请群成员 和 移出群成员(post 和 delete)
exitGroup: `${prefix + URLEnum.ROOM}/group/member/exit`, // 退群
addAdmin: `${prefix + URLEnum.ROOM}/group/admin`, // 添加管理员
@@ -71,7 +71,7 @@ export default {
groupDetail: `${prefix + URLEnum.ROOM}/group`, // 群组详情
groupList: `${prefix + URLEnum.ROOM}/group/list`, // 群聊列表
updateRoomInfo: `${prefix + URLEnum.ROOM}/updateRoomInfo`, // 修改群信息(群主)
updateMyRoomInfo: `${prefix + URLEnum.ROOM}/updateMyRoomInfo`, // 修改“我”的群聊名称
// updateMyRoomInfo: `${prefix + URLEnum.ROOM}/updateMyRoomInfo`, // 修改“我”的群聊名称
searchGroup: `${prefix + URLEnum.ROOM}/search`, // 搜索群聊
applyGroup: `${prefix + URLEnum.ROOM}/applyGroup`, // 申请加群
getAnnouncementList: `${prefix + URLEnum.ROOM}/announcement/list`, // 获取群公告
+39 -5
View File
@@ -4,7 +4,9 @@ import { useGlobalStore } from '@/stores/global'
import type { CacheBadgeItem, CacheUserItem } from '@/services/types'
import { isDiffNow10Min } from '@/utils/ComputedTime.ts'
import { useDebounceFn } from '@vueuse/core'
import { StoresEnum } from '@/enums'
import { StoresEnum, TauriCommand } from '@/enums'
import { invokeWithErrorHandler, invokeSilently, ErrorType } from '@/utils/TauriInvokeHandler.ts'
import { useGroupStore } from './group'
// 定义基础用户信息类型,只包含uid、头像和名称
export type BaseUserItem = Pick<CacheUserItem, 'uid' | 'avatar' | 'name' | 'account'>
@@ -157,7 +159,17 @@ export const useCachedStore = defineStore(StoresEnum.CACHED, () => {
const initAllUserBaseInfo = async () => {
// 这里获取的是全员群的全部用户信息,所以取1作为roomId
if (localStorage.getItem('IS_INIT_USER_BASE') === null) {
const data = await apis.getAllUserBaseInfo({ roomId: 1 })
// const data = await apis.getAllUserBaseInfo({ roomId: 1 })
const data: any = await invokeWithErrorHandler(
TauriCommand.GET_ROOM_MEMBERS,
{
roomId: '1'
},
{
customErrorMessage: '获取房间成员失败',
errorType: ErrorType.Network
}
)
for (const item of data || []) {
userCachedList[item.uid] = item
}
@@ -165,12 +177,26 @@ export const useCachedStore = defineStore(StoresEnum.CACHED, () => {
}
}
const groupStore = useGroupStore()
/** 获取群组内可@的用户基本信息
* 如果是大厅(roomId=1)则不执行
*/
const getGroupAtUserBaseInfo = async () => {
if (currentRoomId.value === '1') return
currentAtUsersList.value = await apis.getAllUserBaseInfo({ roomId: currentRoomId.value })
if (currentRoomId.value === '1' || currentRoomId.value == null) return
const data: any = await invokeWithErrorHandler(
TauriCommand.GET_ROOM_MEMBERS,
{
roomId: currentRoomId.value.toString()
},
{
customErrorMessage: '获取群组成员失败',
errorType: ErrorType.Network
}
)
// 更新 groupStore 中的 userList
groupStore.userList = data
currentAtUsersList.value = data
}
/**
@@ -272,6 +298,13 @@ export const useCachedStore = defineStore(StoresEnum.CACHED, () => {
}
}
const updateMyRoomInfo = async (data: any) => {
await invokeSilently(TauriCommand.UPDATE_MY_ROOM_INFO, {
myRoomInfo: data
})
await getGroupAtUserBaseInfo()
}
return {
userCachedList,
badgeCachedList,
@@ -287,6 +320,7 @@ export const useCachedStore = defineStore(StoresEnum.CACHED, () => {
updateUserGroupNickname,
getUserGroupNickname,
userGroupNicknameMap,
getGroupAnnouncementList
getGroupAnnouncementList,
updateMyRoomInfo
}
})
+40 -33
View File
@@ -2,7 +2,7 @@ import { defineStore } from 'pinia'
import { useRoute } from 'vue-router'
import apis from '@/services/apis'
import type { MarkItemType, MessageType, RevokedMsgType, SessionItem } from '@/services/types'
import { MessageStatusEnum, MsgEnum, NotificationTypeEnum, RoomTypeEnum, StoresEnum } from '@/enums'
import { MessageStatusEnum, MsgEnum, NotificationTypeEnum, RoomTypeEnum, StoresEnum, TauriCommand } from '@/enums'
import { computedTimeBlock } from '@/utils/ComputedTime.ts'
import { useCachedStore } from '@/stores/cached.ts'
import { useGlobalStore } from '@/stores/global.ts'
@@ -12,7 +12,9 @@ import { cloneDeep } from 'lodash-es'
import { useUserStore } from '@/stores/user.ts'
import { renderReplyContent } from '@/utils/RenderReplyContent.ts'
import { sendNotification } from '@tauri-apps/plugin-notification'
import { invoke } from '@tauri-apps/api/core'
import { invokeWithErrorHandler, invokeSilently } from '@/utils/TauriInvokeHandler'
import { ErrorType } from '@/common/exception'
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
type RecalledMessage = {
messageId: string
@@ -45,6 +47,7 @@ export const useChatStore = defineStore(
StoresEnum.CHAT,
() => {
const route = useRoute()
// const router = useRouter()
const cachedStore = useCachedStore()
const userStore = useUserStore()
const globalStore = useGlobalStore()
@@ -152,8 +155,12 @@ export const useChatStore = defineStore(
})
// 监听当前房间ID的变化
watch(currentRoomId, (val, oldVal) => {
if (oldVal !== undefined && val !== oldVal) {
watch(currentRoomId, async (val, oldVal) => {
if (WebviewWindow.getCurrent().label === 'login') {
return
}
if (val !== oldVal) {
// 1. 立即清空当前消息列表
if (currentMessageMap.value) {
currentMessageMap.value.clear()
@@ -189,7 +196,6 @@ export const useChatStore = defineStore(
// 群组的时候去请求
if (currentRoomType.value === RoomTypeEnum.GROUP) {
// 放到和公告一起加载
// groupStore.getGroupUserList(true)
cachedStore.getGroupAtUserBaseInfo()
}
@@ -219,19 +225,25 @@ export const useChatStore = defineStore(
const requestRoomId = currentRoomId.value
currentMessageOptions.value && (currentMessageOptions.value.isLoading = true)
const data = await apis
.getMsgList({
pageSize: size,
cursor: currentMessageOptions.value?.cursor,
roomId: requestRoomId
})
.finally(() => {
// 只有当当前房间ID仍然是请求时的房间ID时,才更新加载状态
if (requestRoomId === currentRoomId.value && currentMessageOptions.value) {
currentMessageOptions.value.isLoading = false
const data: any = await invokeWithErrorHandler(
TauriCommand.PAGE_MSG,
{
param: {
pageSize: size,
cursor: currentMessageOptions.value?.cursor,
roomId: requestRoomId
}
})
},
{
customErrorMessage: '获取消息列表失败',
errorType: ErrorType.Network
}
).finally(() => {
// 只有当当前房间ID仍然是请求时的房间ID时,才更新加载状态
if (requestRoomId === currentRoomId.value && currentMessageOptions.value) {
currentMessageOptions.value.isLoading = false
}
})
// 如果没有数据或者房间ID已经变化,则不处理响应
if (!data || requestRoomId !== currentRoomId.value) return
@@ -274,17 +286,15 @@ export const useChatStore = defineStore(
// 获取会话列表
const getSessionList = async (isFresh = false) => {
if (!isFresh && (sessionOptions.isLast || sessionOptions.isLoading)) return
if (sessionOptions.isLoading) return
sessionOptions.isLoading = true
// TODO: 这里先请求100条会话列表,后续优化
const response = await apis
.getSessionList({
pageSize: sessionList.value.length > 100 ? sessionList.value.length : 100,
cursor: isFresh || !sessionOptions.cursor ? '' : sessionOptions.cursor
})
.catch(() => {
sessionOptions.isLoading = false
})
const response: any = await invokeWithErrorHandler(TauriCommand.LIST_CONTACTS, undefined, {
customErrorMessage: '获取会话列表失败',
errorType: ErrorType.Network
}).catch(() => {
sessionOptions.isLoading = false
return null
})
if (!response) return
const data = response
if (!data) {
@@ -295,10 +305,7 @@ export const useChatStore = defineStore(
const currentSelectedRoomId = globalStore.currentSession.roomId
sessionList.value = []
sessionList.value.push(...data.list)
sessionOptions.cursor = data.cursor
sessionOptions.isLast = data.isLast
sessionList.value.push(...data)
sessionOptions.isLoading = false
sortAndUniqueSessionList()
@@ -315,7 +322,7 @@ export const useChatStore = defineStore(
// 用会话列表第一个去请求消息列表
await getMsgList()
// 请求第一个群成员列表
currentRoomType.value === RoomTypeEnum.GROUP && (await groupStore.getGroupUserList(true))
currentRoomType.value === RoomTypeEnum.GROUP && (await groupStore.getGroupUserList())
// 初始化所有用户基本信息
userStore.isSign && (await cachedStore.initAllUserBaseInfo())
// 联系人列表
@@ -691,7 +698,7 @@ export const useChatStore = defineStore(
// 更新全局 store 中的未读计数
globalStore.unReadMark.newMsgUnreadCount = totalUnread
// 更新系统托盘图标上的未读数
invoke('set_badge_count', { count: totalUnread > 0 ? totalUnread : null })
invokeSilently('set_badge_count', { count: totalUnread > 0 ? totalUnread : null })
}
// 清空所有会话的未读数
+12 -2
View File
@@ -3,7 +3,8 @@ import apis from '@/services/apis'
import { useGlobalStore } from '@/stores/global'
import type { ContactItem, GroupListReq, RequestFriendItem } from '@/services/types'
import { RequestFriendAgreeStatus } from '@/services/types'
import { StoresEnum } from '@/enums'
import { StoresEnum, TauriCommand } from '@/enums'
import { invokeWithErrorHandler, ErrorType } from '@/utils/TauriInvokeHandler.ts'
// 定义分页大小常量
export const pageSize = 20
@@ -60,7 +61,16 @@ export const useContactStore = defineStore(StoresEnum.CONTACTS, () => {
* 获取群聊列表
*/
const getGroupChatList = async () => {
const response = await apis.groupList({ current: 1, size: 50 })
const response: any = await invokeWithErrorHandler(
TauriCommand.PAGE_ROOM,
{
pageParam: { current: 1, size: 50 }
},
{
customErrorMessage: '获取群聊列表失败',
errorType: ErrorType.Network
}
)
groupChatList.value = response.records
}
+2 -2
View File
@@ -4,7 +4,7 @@ import { useChatStore } from '@/stores/chat'
import type { ContactItem, RequestFriendItem } from '@/services/types'
import { clearQueue, readCountQueue } from '@/utils/ReadCountQueue.ts'
import apis from '@/services/apis'
import { invoke } from '@tauri-apps/api/core'
import { invokeSilently } from '@/utils/TauriInvokeHandler'
export const useGlobalStore = defineStore(
StoresEnum.GLOBAL,
@@ -76,7 +76,7 @@ export const useGlobalStore = defineStore(
return total + (session.unreadCount || 0)
}, 0)
unReadMark.newMsgUnreadCount = totalUnread
await invoke('set_badge_count', { count: totalUnread > 0 ? totalUnread : null })
await invokeSilently('set_badge_count', { count: totalUnread > 0 ? totalUnread : null })
}
// 监听当前会话变化
+17 -36
View File
@@ -2,32 +2,12 @@ import apis from '@/services/apis'
import { defineStore } from 'pinia'
import { useGlobalStore } from '@/stores/global'
import type { GroupDetailReq, UserItem } from '@/services/types'
import { pageSize, useChatStore } from './chat'
import { OnlineEnum, RoleEnum, RoomTypeEnum, StoresEnum } from '@/enums'
import { uniqueUserList } from '@/utils/Unique.ts'
import { useChatStore } from './chat'
import { RoleEnum, RoomTypeEnum, StoresEnum, TauriCommand } from '@/enums'
import { useCachedStore } from '@/stores/cached'
import { useUserStore } from '@/stores/user'
import { OnStatusChangeType } from '@/services/wsType'
/**
* 用户排序函数
* 排序规则:
* 1. 在线用户优先显示
* 2. 同为在线或离线状态时,按最后操作时间倒序排序
*/
const sorAction = (pre: UserItem, next: UserItem) => {
if (pre.activeStatus === OnlineEnum.ONLINE && next.activeStatus === OnlineEnum.ONLINE) {
return next.lastOptTime < pre.lastOptTime ? -1 : 1
} else if (pre.activeStatus !== OnlineEnum.ONLINE && next.activeStatus !== OnlineEnum.ONLINE) {
return next.lastOptTime < pre.lastOptTime ? -1 : 1
} else if (pre.activeStatus === OnlineEnum.ONLINE && next.activeStatus !== OnlineEnum.ONLINE) {
return -1
} else if (pre.activeStatus !== OnlineEnum.ONLINE && next.activeStatus === OnlineEnum.ONLINE) {
return 1
} else {
return next.lastOptTime < pre.lastOptTime ? -1 : 1
}
}
import { invokeWithErrorHandler, ErrorType } from '@/utils/TauriInvokeHandler.ts'
export const useGroupStore = defineStore(StoresEnum.GROUP, () => {
// 初始化需要使用的store
@@ -108,19 +88,20 @@ export const useGroupStore = defineStore(StoresEnum.GROUP, () => {
/**
* 获取群成员列表
* @param refresh 是否刷新(重新加载)
*/
const getGroupUserList = async (refresh = false, specifiedRoomId?: string) => {
const data = await apis.getGroupList({
pageSize: pageSize,
cursor: refresh ? '' : userListOptions.cursor,
roomId: specifiedRoomId || currentRoomId.value
})
const getGroupUserList = async () => {
const data: any = await invokeWithErrorHandler(
TauriCommand.GET_ROOM_MEMBERS,
{
roomId: currentRoomId.value
},
{
customErrorMessage: '获取群成员列表失败',
errorType: ErrorType.Network
}
)
if (!data) return
// 合并并去重用户列表,然后按在线状态和时间排序
const newUserList = uniqueUserList(refresh ? data.list : [...data.list, ...userList.value])
newUserList.sort(sorAction)
userList.value = newUserList
userList.value = data
userListOptions.cursor = data.cursor
userListOptions.isLast = data.isLast
userListOptions.loading = false
@@ -220,11 +201,11 @@ export const useGroupStore = defineStore(StoresEnum.GROUP, () => {
*/
const refreshGroupMembers = async () => {
// 始终刷新频道成员列表
await getGroupUserList(true, '1')
await getGroupUserList()
// 如果当前选中的是群聊且不是频道,则同时刷新当前群聊的成员列表
if (globalStore.currentSession?.type === RoomTypeEnum.GROUP && currentRoomId.value !== '1') {
await getGroupUserList(true, currentRoomId.value)
await getGroupUserList()
}
}
+119
View File
@@ -0,0 +1,119 @@
import { invoke } from '@tauri-apps/api/core'
import { AppException, ErrorType } from '@/common/exception'
/**
* Tauri invoke 调用的统一错误处理包装器
* @param command Tauri 命令名称
* @param args 命令参数
* @param options 错误处理选项
* @returns Promise<T>
*/
export async function invokeWithErrorHandler<T = any>(
command: string,
args?: Record<string, any>,
options?: {
/** 是否显示错误提示,默认为 true */
showError?: boolean
/** 自定义错误消息 */
customErrorMessage?: string
/** 是否为重试相关的错误,默认为 false */
isRetryError?: boolean
/** 错误类型,默认为 Unknown */
errorType?: ErrorType
}
): Promise<T> {
const { showError = true, customErrorMessage, isRetryError = false, errorType = ErrorType.Unknown } = options || {}
try {
const result = await invoke<T>(command, args)
return result
} catch (error) {
console.error(`[Tauri Invoke Error] 命令: ${command}`, error)
// 构造错误消息
let errorMessage = customErrorMessage
if (!errorMessage) {
if (typeof error === 'string') {
errorMessage = error
} else if (error instanceof Error) {
errorMessage = error.message
} else {
errorMessage = `调用 ${command} 命令失败`
}
}
// 使用 AppException 统一处理错误
throw new AppException(errorMessage, {
type: errorType,
showError,
isRetryError,
details: {
command,
args,
originalError: error
}
})
}
}
/**
* 静默调用 Tauri 命令(不显示错误提示)
* @param command Tauri 命令名称
* @param args 命令参数
* @returns Promise<T | null> 成功返回结果,失败返回 null
*/
export async function invokeSilently<T = any>(command: string, args?: Record<string, any>): Promise<T | null> {
try {
return await invokeWithErrorHandler<T>(command, args, { showError: false })
} catch {
return null
}
}
/**
* 带重试机制的 Tauri 调用
* @param command Tauri 命令名称
* @param args 命令参数
* @param options 重试选项
* @returns Promise<T>
*/
export async function invokeWithRetry<T = any>(
command: string,
args?: Record<string, any>,
options?: {
/** 最大重试次数,默认为 3 */
maxRetries?: number
/** 重试间隔(毫秒),默认为 1000 */
retryDelay?: number
/** 是否显示错误提示,默认为 true */
showError?: boolean
/** 自定义错误消息 */
customErrorMessage?: string
}
): Promise<T> {
const { maxRetries = 3, retryDelay = 1000, showError = true, customErrorMessage } = options || {}
let lastError: any
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await invokeWithErrorHandler<T>(command, args, {
showError: attempt === maxRetries ? showError : false,
customErrorMessage: attempt === maxRetries ? customErrorMessage : undefined,
isRetryError: attempt < maxRetries,
errorType: ErrorType.Network
})
} catch (error) {
lastError = error
if (attempt < maxRetries) {
console.log(`🔄 重试 ${command} 命令 (${attempt}/${maxRetries})...`)
await new Promise((resolve) => setTimeout(resolve, retryDelay))
}
}
}
throw lastError
}
export { ErrorType }
+2 -2
View File
@@ -112,7 +112,7 @@ import { useSettingStore } from '@/stores/setting.ts'
import { useWindow } from '@/hooks/useWindow.ts'
import { currentMonitor, PhysicalPosition } from '@tauri-apps/api/window'
import { type } from '@tauri-apps/plugin-os'
import { invoke } from '@tauri-apps/api/core'
import { invokeSilently } from '@/utils/TauriInvokeHandler.ts'
const settingStore = useSettingStore()
const { createWebviewWindow, resizeWindow, setResizable } = useWindow()
@@ -293,7 +293,7 @@ const init = async () => {
if (osType === 'macos') {
// 隐藏标题栏按钮
try {
await invoke('hide_title_bar_buttons', { windowLabel: 'checkupdate' })
await invokeSilently('hide_title_bar_buttons', { windowLabel: 'checkupdate' })
} catch (error) {
console.error('隐藏标题栏按钮失败:', error)
}
+25 -1
View File
@@ -192,6 +192,10 @@ import { clearListener } from '@/utils/ReadCountQueue'
import { useGlobalStore } from '@/stores/global'
import { type } from '@tauri-apps/plugin-os'
import { useCheckUpdate } from '@/hooks/useCheckUpdate'
import { emit } from '@tauri-apps/api/event'
import { TauriCommand } from '~/src/enums'
import { invokeWithErrorHandler } from '@/utils/TauriInvokeHandler'
import { ErrorType } from '@/common/exception'
const isCompatibility = computed(() => type() === 'windows' || type() === 'linux')
const settingStore = useSettingStore()
@@ -364,6 +368,7 @@ const normalLogin = async (auto = false) => {
// 存储双token
localStorage.setItem('TOKEN', res.token)
localStorage.setItem('REFRESH_TOKEN', res.refreshToken)
// 需要删除二维码,因为用户可能先跳转到二维码界面再回到登录界面,会导致二维码一直保持在内存中
if (localStorage.getItem('wsLogin')) {
localStorage.removeItem('wsLogin')
@@ -386,16 +391,35 @@ const normalLogin = async (auto = false) => {
const account = {
...userDetail,
token: res.token,
refreshToken: res.refreshToken,
client: res.client
}
userStore.userInfo = account
loginHistoriesStore.addLoginHistory(account)
await invokeWithErrorHandler(
TauriCommand.SAVE_USER_INFO,
{
userInfo: account
},
{
customErrorMessage: '保存用户信息失败',
errorType: ErrorType.Client
}
)
await emit('set_user_info', {
token: res.token,
refreshToken: res.refreshToken,
uid: account.uid
})
await setLoginState()
await openHomeWindow()
loading.value = false
})
.catch(() => {
.catch((e) => {
console.error('登录异常:', e)
loading.value = false
loginDisabled.value = false
loginText.value = '登录'
+1 -1
View File
@@ -55,7 +55,7 @@ const filteredOptions = computed(() => getFilteredOptions())
// 初始化群成员数据
const initGroupMembers = async () => {
if (globalStore.currentSession?.roomId) {
await groupStore.getGroupUserList(true, globalStore.currentSession.roomId)
await groupStore.getGroupUserList()
}
}
+12 -5
View File
@@ -146,7 +146,7 @@
</template>
<script setup lang="ts">
import { invoke } from '@tauri-apps/api/core'
import { invokeWithErrorHandler, invokeSilently, ErrorType } from '@/utils/TauriInvokeHandler.ts'
import { listen } from '@tauri-apps/api/event'
import { open } from '@tauri-apps/plugin-dialog'
import { appCacheDir } from '@tauri-apps/api/path'
@@ -273,9 +273,16 @@ const startScan = async () => {
}
try {
const result = await invoke<DirectoryInfo>('get_directory_usage_info_with_progress', {
directoryPath: currentDirectory.value
})
const result = await invokeWithErrorHandler<DirectoryInfo>(
'get_directory_usage_info_with_progress',
{
directoryPath: currentDirectory.value
},
{
customErrorMessage: '获取目录信息失败',
errorType: ErrorType.Client
}
)
diskInfo.value = result
totalSize.value = result.total_size
@@ -290,7 +297,7 @@ const startScan = async () => {
// 取消扫描的方法
const cancelScan = async () => {
try {
await invoke('cancel_directory_scan')
await invokeSilently('cancel_directory_scan')
console.log('扫描已取消')
} catch (error) {
console.error('取消扫描失败:', error)