fix(common): 🐛 fix some defects and improve mobile terminal (#335)

* fix(common): 🐛 chatmain scroll behavior

* fix(common): 🐛 chatMain scroll bug

* fix(common): 🐛 p2p chat sendTime

* feat(mobile):  added friend/group page and functionality

* fix(common): 🐛 order group member

* style(common): 💄 refactor layout center

* fix(agreement): 🐛 chat and announcements

* fix(common): 🐛 group basic information editing

* refactor(common): ♻️ union config

* ci(config): 🎡 change some configuration

* refactor(common): ♻️ config yudao

* fix(common): 🐛 exit group

* refactor(common): ♻️ tencent config

* fix(common): 🐛 network config

* feat(mobile):  added Friend or Group Message Page

* fix(common): 🐛 exit group error

* feat(message):  add message management settings

* fix(mobile): 🐛 fix the issue where the mobile client fails to locate the configuration file

* fix(mobile): 🐛 resolve mobile misidentification of local and production environments

* feat(mobile):  optimize the maximum width of bot messages and reply messages

* feat(mobile):  optimize the maximum width of image messages

* feat(common):  msg multi choose relay

* build(vite): 📦 update startup and vite configuration

* perf(message forwarding):  optimize message forwarding styles

* fix(common): 🐛 msg time block

* feat(mobile):  added ability to view detailed information about friends

* feat(mobile):  add functionality to remove friends

* fix(style): 🐛 fix special message not centered issue

* fix(mobile): 🐛 fix hmr host bug

* fix(mobile): 🐛 fix contact page not showing friend requests

* feat(common):  msg merge

* feat(mobile):  added chatroom redirection feature on friend detail page

* fix(common): 🐛 merge msg display

* feat(mobile):  add file and image uploader components

* fix(common): 🐛 send new msg error when scroll too top

* fix(common): 🐛 reconstruct the notification module

* fix(system): 🐛 fix some style and logic issues

---------

Co-authored-by: wanwanruwoxin <254693270@qq.com>
Co-authored-by: 卡仔 <1271013637@qq.com>
Co-authored-by: 乾乾 <1046762075@qq.com>
This commit is contained in:
Dawn
2025-09-18 19:53:30 +08:00
committed by GitHub
parent 4f45070d2b
commit d2be80a5db
100 changed files with 3746 additions and 1689 deletions
+2
View File
@@ -0,0 +1,2 @@
# gitee token
VITE_GITEE_TOKEN="a9029798336825cea39ac9e4413b8579"
-15
View File
@@ -1,15 +0,0 @@
# 后端服务地址
VITE_SERVICE_URL="https://hulaspark.com/api"
# # # websocket服务地址
VITE_WEBSOCKET_URL="wss://hulaspark.com/api/ws/ws"
# 项目标题
VITE_APP_TITLE="HuLa—IM"
# 项目名称
VITE_APP_NAME="HuLa"
# gitee token
VITE_GITEE_TOKEN="a9029798336825cea39ac9e4413b8579"
VITE_USE_RUST_WEBSOCKET=true
TAURI_DEV_HOST=192.168.1.26
-14
View File
@@ -1,14 +0,0 @@
# 后端服务地址
VITE_SERVICE_URL="https://hulaspark.com/api"
# # # websocket服务地址
VITE_WEBSOCKET_URL="wss://hulaspark.com/api/ws/ws"
# 项目标题
VITE_APP_TITLE="HuLa—IM"
# 项目名称
VITE_APP_NAME="HuLa"
# gitee token
VITE_GITEE_TOKEN="a9029798336825cea39ac9e4413b8579"
VITE_USE_RUST_WEBSOCKET=true
TAURI_DEV_HOST=192.168.1.26
+8 -3
View File
@@ -25,6 +25,10 @@
"tauri:dev": "tauri dev",
"========= 启动HuLa桌面应用程序(简化命令) =========": "",
"td": "tauri dev",
"========= windows中启动安卓纯前端(简化命令) =========": "",
"adev:win": "set TAURI_ENV_PLATFORM=android&& vite",
"========= mac中启动ios纯前端(简化命令) =========": "",
"idev:mac": "TAURI_ENV_PLATFORM=ios vite",
"========= 启动安卓程序(简化命令) =========": "",
"adev": "tauri android dev",
"========= 启动ios程序(简化命令) =========": "",
@@ -107,6 +111,7 @@
"file-type": "^21.0.0",
"grapheme-splitter": "^1.0.4",
"hula-emojis": "^1.2.26",
"internal-ip": "^8.0.0",
"lodash-es": "^4.17.21",
"mitt": "^3.0.1",
"naive-ui": "^2.43.1",
@@ -147,7 +152,7 @@
"@vitest/ui": "^3.2.4",
"@vue/test-utils": "^2.4.6",
"@vueuse/core": "^13.7.0",
"chalk": "^5.6.0",
"chalk": "^5.6.2",
"commitizen": "^4.3.1",
"cz-git": "^1.12.0",
"happy-dom": "^15.11.7",
@@ -156,11 +161,11 @@
"postcss-pxtorem": "^6.1.0",
"prettier": "^3.6.2",
"release-it": "^17.11.0",
"sass": "1.90.0",
"sass": "1.92.1",
"typescript": "^5.9.2",
"unplugin-auto-import": "^20.1.0",
"unplugin-vue-components": "^29.0.0",
"vite": "7.1.5",
"vite": "7.1.6",
"vite-plugin-vue-setup-extend": "^0.4.0",
"vitest": "^3.2.4",
"vue-tsc": "^3.0.6"
Generated Vendored
+394 -247
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
File diff suppressed because one or more lines are too long
-4
View File
@@ -31,10 +31,6 @@ async function main() {
/** @type {CheckItem[]} */
const checks = [
{
script: join(__dirname, 'check-env.js'),
description: '配置文件检查'
},
{
script: join(__dirname, 'check-dependencies.js'),
description: '环境检查'
-3
View File
@@ -1,3 +0,0 @@
RUST_BACKTRACE=1
# APP_ENVIRONMENT=local
APP_ENVIRONMENT=production
+1
View File
@@ -1,4 +1,5 @@
# Generated by Cargo
# will have compiled files and executables
/target/
local.yaml
+1
View File
@@ -2,3 +2,4 @@ database:
sqlite_file: db.sqlite
backend:
base_url: https://hulaspark.com/api
active_config: local
-4
View File
@@ -1,4 +0,0 @@
database:
sqlite_file: db.sqlite
backend:
base_url: http://192.168.1.37:18760
+8
View File
@@ -2,3 +2,11 @@ database:
sqlite_file: db.sqlite
backend:
base_url: https://hulaspark.com/api
ws_url: wss://hulaspark.com/api/ws/ws
youdao:
app_key: ''
app_secret: ''
tencent:
api_key: ''
secret_id: ''
+1
View File
@@ -22,6 +22,7 @@ pub struct Model {
pub login_uid: String,
/// 消息发送状态: pending, success, fail
pub send_status: String,
pub time_block: Option<i64>,
}
impl ActiveModelBehavior for ActiveModel {}
+2 -1
View File
@@ -3,4 +3,5 @@
/src/main/assets/tauri.conf.json
/tauri.build.gradle.kts
/proguard-tauri.pro
/tauri.properties
/tauri.properties
/src/main/assets/configuration
+19 -35
View File
@@ -1,41 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.INTERNET" />
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-permission android:name="android.permission.CAMERA"/>
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.hula"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:launchMode="singleTask"
android:label="@string/main_activity_title"
android:name=".MainActivity"
android:exported="true"
android:resizeableActivity="true"
android:windowSoftInputMode="adjustNothing"
android:theme="@style/Theme.hula">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- AndroidTV support -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<application android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:theme="@style/Theme.hula" android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode" android:launchMode="singleTask" android:label="@string/main_activity_title" android:name=".MainActivity" android:exported="true" android:resizeableActivity="true" android:windowSoftInputMode="adjustNothing" android:theme="@style/Theme.hula">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- AndroidTV support -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
<provider android:name="androidx.core.content.FileProvider" android:authorities="${applicationId}.fileprovider" android:exported="false" android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -1,4 +0,0 @@
database:
sqlite_file: db.sqlite
backend:
base_url: https://hulaspark.com/api
@@ -1,4 +0,0 @@
database:
sqlite_file: db.sqlite
backend:
base_url: http://192.168.1.37:18760
@@ -1,4 +0,0 @@
database:
sqlite_file: db.sqlite
backend:
base_url: https://hulaspark.com/api
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
</array>
</dict>
</plist>
+2
View File
@@ -3,6 +3,7 @@ pub use sea_orm_migration::prelude::*;
mod m20220101_000001_create_table;
mod m20241220_000002_add_token_field;
mod m20241220_000003_add_refresh_token_field;
mod m20250917_000001_update_msg_table;
pub struct Migrator;
@@ -13,6 +14,7 @@ impl MigratorTrait for Migrator {
Box::new(m20220101_000001_create_table::Migration),
Box::new(m20241220_000002_add_token_field::Migration),
Box::new(m20241220_000003_add_refresh_token_field::Migration),
Box::new(m20250917_000001_update_msg_table::Migration),
]
}
}
@@ -0,0 +1,35 @@
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> {
manager.alter_table(
Table::alter()
.table(ImMessage::Table)
.add_column(ColumnDef::new(ImMessage::TimeBlock).big_integer())
.to_owned()
).await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.alter_table(
Table::alter()
.table(ImMessage::Table)
.drop_column(ImMessage::TimeBlock)
.to_owned()
).await?;
Ok(())
}
}
#[derive(DeriveIden)]
enum ImMessage {
Table,
TimeBlock,
}
@@ -84,7 +84,7 @@ pub async fn query_chat_history(
// 转换为响应格式
let message_resps: Vec<MessageResp> = messages
.into_iter()
.map(|msg| crate::command::message_command::convert_message_to_resp(msg))
.map(|msg| crate::command::message_command::convert_message_to_resp(msg, None))
.collect();
// 根据返回的消息数量判断是否还有更多数据
+25 -17
View File
@@ -25,6 +25,7 @@ pub struct MessageResp {
pub from_user: FromUser,
pub message: Message,
pub old_msg_id: Option<String>,
pub time_block: Option<i64>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
@@ -95,6 +96,7 @@ pub async fn page_msg(
param: CursorPageMessageParam,
state: State<'_, AppData>,
) -> Result<CursorPageResp<Vec<MessageResp>>, String> {
info!("get page msg");
// 获取当前登录用户的 uid
let login_uid = {
let user_info = state.user_info.lock().await;
@@ -116,7 +118,7 @@ pub async fn page_msg(
.list
.unwrap_or_default()
.into_iter()
.map(|msg| convert_message_to_resp(msg))
.map(|msg| convert_message_to_resp(msg, None))
.rev()
.collect();
@@ -129,7 +131,7 @@ pub async fn page_msg(
}
/// 将数据库消息模型转换为响应模型
pub fn convert_message_to_resp(msg: im_message::Model) -> MessageResp {
pub fn convert_message_to_resp(msg: im_message::Model, old_msg_id: Option<String>) -> MessageResp {
// 解析消息体 - 安全地处理 JSON 解析
let body = msg.body.as_ref().and_then(|body_str| {
if body_str.trim().is_empty() {
@@ -193,7 +195,8 @@ pub fn convert_message_to_resp(msg: im_message::Model) -> MessageResp {
message_marks,
send_time: msg.send_time,
},
old_msg_id: None,
old_msg_id: old_msg_id,
time_block: msg.time_block,
}
}
@@ -254,7 +257,7 @@ pub async fn fetch_all_messages(
uid, last_opt_time
);
// 调用后端接口 /chat/msg/list 获取所有消息,传递 last_opt_time 参数
let params = if let Some(time) = last_opt_time {
let body = if let Some(time) = last_opt_time {
Some(serde_json::json!({
"lastOptTime": time
}))
@@ -263,7 +266,7 @@ pub async fn fetch_all_messages(
};
let messages: Option<Vec<MessageResp>> = client
.im_request(ImUrl::GetMsgList, None::<serde_json::Value>, params)
.im_request(ImUrl::GetMsgList, body, None::<serde_json::Value>)
.await?;
if let Some(messages) = messages {
@@ -332,6 +335,7 @@ fn convert_resp_to_model_for_fetch(msg_resp: MessageResp, uid: String) -> im_mes
update_time: msg_resp.update_time,
login_uid: uid.to_string(), // 这里暂时设为空字符串,实际使用时会在 save_all 中设置
send_status: "success".to_string(), // 从后端获取的消息默认为成功状态
time_block: msg_resp.time_block,
}
}
@@ -376,6 +380,7 @@ pub async fn send_msg(
update_time: Some(current_time),
login_uid: login_uid.clone(),
send_status: "pending".to_string(), // 初始状态为pending
time_block: None,
};
let tx = state
@@ -418,28 +423,30 @@ pub async fn send_msg(
Ok(Some(mut resp)) => {
resp.old_msg_id = Some(msg_id.clone());
id = resp.message.id.clone();
// 尝试克隆数据以避免所有权问题
let event_data = resp.clone();
success_channel.send(event_data).unwrap();
"success"
}
_ => {
error_channel.send(msg_id.clone()).unwrap();
"fail"
}
_ => "fail",
};
// 更新消息状态
if let Err(e) = im_message_repository::update_message_status(
let model = im_message_repository::update_message_status(
db_conn.deref(),
&msg_id,
message,
status,
id,
login_uid.clone(),
)
.await
{
error!("Failed to update message status: {}", e);
.await;
match model {
Ok(model) => {
let resp = convert_message_to_resp(model, Some(msg_id));
success_channel.send(resp).unwrap();
}
Err(e) => {
error!("{:?}", e);
error_channel.send(msg_id.clone()).unwrap();
}
}
});
@@ -448,6 +455,7 @@ pub async fn send_msg(
#[tauri::command]
pub async fn save_msg(data: MessageResp, state: State<'_, AppData>) -> Result<(), String> {
info!("save msg: {:?}", serde_json::to_string(&data));
// 创建 im_message::Model
let message = convert_resp_to_model_for_fetch(data, state.user_info.lock().await.uid.clone());
+1
View File
@@ -5,3 +5,4 @@ pub mod message_mark_command;
pub mod request_command;
pub mod room_member_command;
pub mod user_command;
pub mod setting_command;
+28
View File
@@ -0,0 +1,28 @@
use serde::{Deserialize, Serialize};
use tauri::State;
use tracing::info;
use crate::{configuration::Settings, AppData};
#[tauri::command]
pub async fn get_settings(state: State<'_, AppData>,) -> Result<Settings, String> {
Ok(state.config.lock().await.clone())
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct UpdateSettingsParams {
base_url: String,
ws_url: String,
}
#[tauri::command]
pub async fn update_settings(state: State<'_, AppData>, settings: UpdateSettingsParams) -> Result<(), String> {
let mut config = state.config.lock().await;
config.backend.base_url = settings.base_url.clone();
config.backend.ws_url = settings.ws_url;
info!("update settings: {:?}", config);
state.rc.lock().await.set_base_url(settings.base_url.clone());
Ok(())
}
+121 -197
View File
@@ -6,22 +6,37 @@ use tauri::{AppHandle, Manager};
use tracing::info;
// 应用程序设置结构体
#[derive(serde::Deserialize, Clone, Debug)]
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Settings {
pub database: DatabaseSettings,
pub backend: BackendSettings,
pub youdao: Youdao,
}
// 数据库配置设置
#[derive(serde::Deserialize, Clone, Debug)]
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug)]
pub struct DatabaseSettings {
pub sqlite_file: String,
}
// 后端服务配置设置
#[derive(serde::Deserialize, Clone, Debug)]
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug)]
pub struct BackendSettings {
pub base_url: String,
pub ws_url: String,
}
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug)]
pub struct Youdao {
pub app_key: String,
pub app_secret: String,
}
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug)]
pub struct Tencent {
pub api_key: String,
pub secret_id: String,
}
// 应用程序运行环境枚举
@@ -31,13 +46,6 @@ pub enum Environment {
Production,
}
// 配置文件来源枚举
enum ConfigSource {
FileSystem(PathBuf, PathBuf),
Resource(PathBuf, PathBuf),
Embedded,
}
impl DatabaseSettings {
/// 创建数据库连接
/// 根据不同的运行环境(桌面开发、移动端、桌面生产)选择合适的数据库路径
@@ -150,204 +158,120 @@ impl TryFrom<String> for Environment {
/// * `Ok(Settings)` - 成功时返回配置设置
/// * `Err(config::ConfigError)` - 失败时返回配置错误
pub fn get_configuration(app_handle: &AppHandle) -> Result<Settings, config::ConfigError> {
let environment: Environment = std::env::var("APP_ENVIRONMENT")
.unwrap_or_else(|_| "local".into())
.try_into()
.map_err(|e| {
config::ConfigError::Message(format!("Failed to parse APP_ENVIRONMENT: {:?}", e))
#[cfg(not(target_os = "android"))]
{
let is_desktop_dev = cfg!(debug_assertions) && cfg!(desktop);
let config_path_buf = get_config_path_buf(app_handle, is_desktop_dev)?;
let settings = config::Config::builder()
.add_source(config::File::from(config_path_buf.0))
.add_source(config::File::from(config_path_buf.1))
.add_source(
config::Environment::with_prefix("APP")
.prefix_separator("_")
.separator("__"),
)
.build()?;
settings.try_deserialize::<Settings>()
}
#[cfg(target_os = "android")]
{
// 读取 base.yaml 内容
let base_content = std::str::from_utf8(include_bytes!("../configuration/base.yaml"))
.map_err(|e| config::ConfigError::Message(e.to_string()))?;
// 构建 base 配置对象
let base_config = config::Config::builder()
.add_source(config::File::from_str(
base_content,
config::FileFormat::Yaml,
))
.build()?;
// 获取 active_config 字段
let active_config = base_config.get_string("active_config").map_err(|_| {
config::ConfigError::Message(
"Missing or invalid 'active_config' in base.yaml".to_string(),
)
})?;
info!("APP_ENVIRONMENT: {}", environment.as_str());
let environment_filename = format!("{}.yaml", environment.as_str());
let is_desktop_dev = cfg!(debug_assertions) && cfg!(desktop);
// 校验 active_config 合法性
if active_config != "local" && active_config != "production" {
return Err(config::ConfigError::Message(
"Only \"local\" or \"production\" can be specified in active_config".to_string(),
));
}
match load_config_source(app_handle, &environment_filename, is_desktop_dev) {
ConfigSource::FileSystem(base_path, env_path) => {
tracing::info!("Using filesystem configuration files");
build_config_from_files(base_path, env_path)
}
ConfigSource::Resource(base_path, env_path) => {
tracing::info!("Using Resource directory configuration files");
build_config_from_files(base_path, env_path)
}
ConfigSource::Embedded => {
tracing::info!("Using embedded configuration");
build_config_from_embedded(&environment)
}
// 加载对应的配置文件内容
let config_file_bytes: &[u8] = match active_config.as_str() {
"local" => include_bytes!("../configuration/local.yaml").as_ref(),
"production" => include_bytes!("../configuration/production.yaml").as_ref(),
_ => return Err(config::ConfigError::Message("Invalid active_config".into())),// 这里可以支持更多的环境配置
};
let active_content = std::str::from_utf8(config_file_bytes)
.map_err(|e| config::ConfigError::Message(e.to_string()))?;
// 构建最终配置对象
config::Config::builder()
.add_source(config::File::from_str(
base_content,
config::FileFormat::Yaml,
))
.add_source(config::File::from_str(
active_content,
config::FileFormat::Yaml,
))
.add_source(
config::Environment::with_prefix("APP")
.prefix_separator("_")
.separator("__"),
)
.build()?
.try_deserialize::<Settings>()
}
}
/// 加载配置文件来源
/// 根据运行环境和配置文件可用性确定配置来源
/// 优先级:文件系统 > 资源目录 > 嵌入式配置
///
/// # 参数
/// * `app_handle` - Tauri应用句柄
/// * `environment_filename` - 环境配置文件名
/// * `is_desktop_dev` - 是否为桌面开发环境
///
/// # 返回值
/// * `ConfigSource` - 配置文件来源枚举
fn load_config_source(
fn get_config_path_buf(
app_handle: &AppHandle,
environment_filename: &str,
is_desktop_dev: bool,
) -> ConfigSource {
if is_desktop_dev {
if let Ok(config_dir) = get_configuration_directory(app_handle) {
let base_path = config_dir.join("base.yaml");
let env_path = config_dir.join(environment_filename);
return ConfigSource::FileSystem(base_path, env_path);
}
}
) -> Result<(PathBuf, PathBuf), config::ConfigError> {
let dir = if is_desktop_dev {
let base_path = std::env::current_dir().map_err(|e| {
config::ConfigError::Message(format!("Failed to get current dir: {}", e))
})?;
if let Ok(config_dir) = app_handle
.path()
.resolve("configuration", tauri::path::BaseDirectory::Resource)
{
let base_path = config_dir.join("base.yaml");
let env_path = config_dir.join(environment_filename);
if base_path.exists() && env_path.exists() {
return ConfigSource::Resource(base_path, env_path);
} else {
tracing::warn!(
"Resource directory configuration files do not exist, falling back to embedded config"
);
}
base_path.join("configuration")
} else {
tracing::warn!("Failed to get Resource directory, falling back to embedded config");
}
ConfigSource::Embedded
}
/// 从配置文件构建配置对象
/// 加载基础配置文件和环境特定配置文件,并合并环境变量配置
///
/// # 参数
/// * `base_path` - 基础配置文件路径
/// * `env_path` - 环境配置文件路径
///
/// # 返回值
/// * `Ok(Settings)` - 成功时返回配置设置
/// * `Err(config::ConfigError)` - 失败时返回配置错误
fn build_config_from_files(
base_path: PathBuf,
env_path: PathBuf,
) -> Result<Settings, config::ConfigError> {
let settings = config::Config::builder()
.add_source(config::File::from(base_path))
.add_source(config::File::from(env_path))
.add_source(
config::Environment::with_prefix("APP")
.prefix_separator("_")
.separator("__"),
)
.build()?;
settings.try_deserialize::<Settings>()
}
/// 从嵌入式配置构建配置对象
/// 使用编译时嵌入的配置文件内容构建配置
///
/// # 参数
/// * `environment` - 运行环境枚举
///
/// # 返回值
/// * `Ok(Settings)` - 成功时返回配置设置
/// * `Err(config::ConfigError)` - 失败时返回配置错误
fn build_config_from_embedded(environment: &Environment) -> Result<Settings, config::ConfigError> {
let base_config = include_str!("../configuration/base.yaml");
let env_config = match environment.as_str() {
"local" => include_str!("../configuration/local.yaml"),
"production" => include_str!("../configuration/production.yaml"),
_ => {
return Err(config::ConfigError::Message(format!(
"Unsupported environment: {}",
environment.as_str()
)));
}
};
let settings = config::Config::builder()
.add_source(config::File::from_str(
base_config,
config::FileFormat::Yaml,
))
.add_source(config::File::from_str(env_config, config::FileFormat::Yaml))
.add_source(
config::Environment::with_prefix("APP")
.prefix_separator("_")
.separator("__"),
)
.build()?;
settings.try_deserialize::<Settings>()
}
/// 获取配置文件目录路径
/// 根据运行环境选择合适的配置文件目录:
/// - 桌面开发环境:项目根目录/configuration
/// - 其他环境:优先使用Resource目录,失败则使用app_data_dir
///
/// # 参数
/// * `app_handle` - Tauri应用句柄
///
/// # 返回值
/// * `Ok(PathBuf)` - 成功时返回配置目录路径
/// * `Err(config::ConfigError)` - 失败时返回配置错误
fn get_configuration_directory(app_handle: &AppHandle) -> Result<PathBuf, config::ConfigError> {
// 配置目录路径策略:
// 桌面端开发环境:使用项目根目录
// 其他情况:使用 Resource 目录
let config_path = if cfg!(debug_assertions) && cfg!(desktop) {
let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
path.push("configuration");
path
} else {
// 非桌面端开发环境:优先使用 Resource 目录,失败则使用 app_data_dir
match app_handle
app_handle
.path()
.resolve("configuration", tauri::path::BaseDirectory::Resource)
{
Ok(path) => {
info!("Using Resource configuration directory: {:?}", path);
path
}
Err(e) => {
tracing::warn!(
"Failed to get Resource configuration directory: {}, trying app_data_dir",
e
);
match app_handle.path().app_data_dir() {
Ok(app_data_dir) => {
let config_dir = app_data_dir.join("configuration");
if let Err(create_err) = std::fs::create_dir_all(&config_dir) {
tracing::warn!(
"Failed to create app_data_dir configuration directory: {}",
create_err
);
}
info!(
"Using backup app_data_dir configuration directory: {:?}",
config_dir
);
config_dir
}
Err(app_data_err) => {
let error_msg = format!(
"Failed to get both Resource and app_data_dir configuration directories: Resource error={}, app_data_dir error={}",
e, app_data_err
);
tracing::error!("{}", error_msg);
return Err(config::ConfigError::Message(error_msg));
}
}
}
}
.resource_dir()
.map_err(|e| config::ConfigError::NotFound(format!("resource not find: {}", e)))?
.join("configuration")
};
info!("Configuration file directory: {:?}", config_path);
Ok(config_path)
let base_path = dir.join("base.yaml");
#[cfg(not(target_os = "android"))]
let base_config = config::Config::builder()
.add_source(config::File::from(base_path.clone()))
.build()?;
#[cfg(target_os = "android")]
let base_config = {
let content = std::str::from_utf8(include_bytes!("../configuration/base.yaml"))
.map_err(|e| config::ConfigError::Message(e.to_string()))?;
config::Config::builder()
.add_source(config::File::from_str(content, config::FileFormat::Yaml))
.build()?
};
let active_config = base_config.get_string("active_config")?;
println!("active_config: {:?}", active_config);
let active_config_path_buf = dir.clone().join(active_config);
Ok((base_path, active_config_path_buf))
}
+1 -35
View File
@@ -10,7 +10,7 @@ use std::sync::{Arc, RwLock};
use std::thread;
use std::time::Duration;
use tauri::path::BaseDirectory;
use tauri::{AppHandle, Emitter, LogicalSize, Manager, ResourceId, Runtime, Webview};
use tauri::{AppHandle, LogicalSize, Manager, ResourceId, Runtime, Webview};
#[cfg(target_os = "macos")]
use objc2::rc::Retained;
@@ -32,12 +32,6 @@ pub struct WindowsScaleInfo {
/// 是否检测到文本缩放
pub has_text_scaling: bool,
}
use crate::desktops::window_payload::{
WindowPayload, get_window_payload as _get_window_payload,
push_window_payload as _push_window_payload,
};
// 定义用户信息结构体
#[derive(Debug, Clone, Serialize)]
pub struct UserInfo {
@@ -246,34 +240,6 @@ pub fn show_title_bar_buttons(window_label: &str, handle: AppHandle) -> Result<(
Ok(())
}
#[tauri::command]
pub async fn push_window_payload(
label: String,
payload: serde_json::Value,
handle: AppHandle,
) -> Result<(), String> {
let payload_entity = WindowPayload::new(payload);
if let Some(window) = handle.get_webview_window(&label) {
window
// 找到了对应label的window说明已经打开了,就只需要提醒刷新就行了
.emit(&format!("{}:update", label), payload_entity)
.map_err(|e| e.to_string())
} else {
_push_window_payload(label, payload_entity)
.await
// 这里是存在值的时候才算失败,不存在值则是插入成功
.map_or_else(|| Ok(()), |_| Err("none".to_string()))
}
}
#[tauri::command]
pub async fn get_window_payload(label: String) -> Result<serde_json::Value, ()> {
_get_window_payload(label)
.await
.map(|payload_entity| payload_entity.payload)
.ok_or(())
}
#[tauri::command]
pub async fn get_files_meta(files_path: Vec<String>) -> Result<Vec<FileMeta>, String> {
let mut files_meta: Vec<FileMeta> = Vec::new();
+5 -9
View File
@@ -9,22 +9,18 @@ pub struct WindowPayload {
pub payload: serde_json::Value,
}
impl WindowPayload {
pub fn new(payload: serde_json::Value) -> Self {
WindowPayload { payload }
}
}
lazy_static! {
static ref PAYLOAD_CACHE: Mutex<HashMap<String, WindowPayload>> = Mutex::new(HashMap::new());
static ref PAYLOAD_CACHE: Mutex<HashMap<String, serde_json::Value>> = Mutex::new(HashMap::new());
}
pub async fn push_window_payload(label: String, payload: WindowPayload) -> Option<WindowPayload> {
#[tauri::command]
pub async fn push_window_payload(label: String, payload: serde_json::Value) -> Option<serde_json::Value> {
let mut payload_cache = PAYLOAD_CACHE.lock().await;
payload_cache.insert(label, payload)
}
pub async fn get_window_payload(label: String) -> Option<WindowPayload> {
#[tauri::command]
pub async fn get_window_payload(label: String) -> Option<serde_json::Value> {
let mut payload_cache = PAYLOAD_CACHE.lock().await;
payload_cache.remove(&label)
}
+20 -2
View File
@@ -43,6 +43,10 @@ impl ImRequestClient {
})
}
pub fn set_base_url(&mut self, base_url: String) {
self.base_url = base_url;
}
pub async fn request<
T: serde::de::DeserializeOwned,
B: serde::Serialize,
@@ -102,7 +106,11 @@ impl ImRequestClient {
return Err(anyhow::anyhow!("请重新登录"));
}
Some(200) => {
info!("✅ Request successful: {}, Method: {}", &url, method.clone());
info!(
"✅ Request successful: {}, Method: {}",
&url,
method.clone()
);
return Ok(result);
}
_ => {
@@ -260,6 +268,7 @@ pub enum ImUrl {
HandleInvite,
ApplyUnReadCount,
RequestApplyPage,
RequestNoticePage,
GetContactList,
SearchFriend,
ChangeUserState,
@@ -285,6 +294,8 @@ pub enum ImUrl {
GetFriendPage,
MarkMsgRead,
CheckEmail,
MergeMsg,
GetUserByIds,
}
impl ImUrl {
@@ -360,6 +371,7 @@ impl ImUrl {
ImUrl::HandleInvite => (http::Method::POST, "im/room/apply/handler/apply"),
ImUrl::ApplyUnReadCount => (http::Method::GET, "im/room/apply/unread"),
ImUrl::RequestApplyPage => (http::Method::GET, "im/room/apply/page"),
ImUrl::RequestNoticePage => (http::Method::GET, "im/room/notice/page"),
ImUrl::GetFriendPage => (http::Method::GET, "im/user/friend/page"),
ImUrl::GetContactList => (http::Method::GET, "im/chat/contact/list"),
ImUrl::SearchFriend => (http::Method::GET, "im/user/friend/search"),
@@ -384,12 +396,15 @@ impl ImUrl {
ImUrl::RecallMsg => (http::Method::PUT, "im/chat/msg/recall"),
ImUrl::MarkMsg => (http::Method::PUT, "im/chat/msg/mark"),
ImUrl::GetMsgPage => (http::Method::GET, "im/chat/msg/page"),
ImUrl::GetMsgList => (http::Method::GET, "im/chat/msg/list"),
ImUrl::GetMsgList => (http::Method::POST, "im/chat/msg/list"),
ImUrl::GetMemberStatistic => (http::Method::GET, "im/chat/member/statistic"),
// 群成员信息
ImUrl::GetAllUserBaseInfo => (http::Method::GET, "im/room/group/member/list"),
ImUrl::CheckEmail => (http::Method::GET, "oauth/anyTenant/checkEmail"),
ImUrl::MergeMsg => (http::Method::POST, "im/room/mergeMessage"),
ImUrl::GetUserByIds => (http::Method::POST, "im/user/getUserByIds"),
}
}
@@ -461,6 +476,7 @@ impl ImUrl {
"handleInvite" => Ok(ImUrl::HandleInvite),
"applyUnReadCount" => Ok(ImUrl::ApplyUnReadCount),
"requestApplyPage" => Ok(ImUrl::RequestApplyPage),
"requestNoticePage" => Ok(ImUrl::RequestNoticePage),
"getContactList" => Ok(ImUrl::GetContactList),
"searchFriend" => Ok(ImUrl::SearchFriend),
@@ -495,6 +511,8 @@ impl ImUrl {
"markMsgRead" => Ok(ImUrl::MarkMsgRead),
"groupListMember" => Ok(ImUrl::GroupListMember),
"checkEmail" => Ok(ImUrl::CheckEmail),
"mergeMsg" => Ok(ImUrl::MergeMsg),
"getUserByIds" => Ok(ImUrl::GetUserByIds),
// 未匹配的字符串
_ => Err(anyhow::anyhow!("未知的URL类型: {}", s)),
+26 -13
View File
@@ -6,21 +6,21 @@ use common::init::CustomInit;
#[cfg(target_os = "windows")]
use common_cmd::get_windows_scale_info;
#[cfg(desktop)]
use common_cmd::{
audio, default_window_icon, get_files_meta, get_window_payload, push_window_payload,
screenshot, set_height,
};
use common_cmd::{audio, default_window_icon, get_files_meta, screenshot, set_height};
#[cfg(target_os = "macos")]
use common_cmd::{hide_title_bar_buttons, set_window_level_above_menubar, show_title_bar_buttons};
#[cfg(target_os = "macos")]
use desktops::app_event;
#[cfg(desktop)]
use desktops::window_payload::{get_window_payload, push_window_payload};
#[cfg(desktop)]
use desktops::{common_cmd, directory_scanner, init, tray, video_thumbnail::get_video_thumbnail};
#[cfg(desktop)]
use directory_scanner::{cancel_directory_scan, get_directory_usage_info_with_progress};
#[cfg(desktop)]
use init::DesktopCustomInit;
use std::sync::Arc;
use tauri_plugin_fs::FsExt;
pub mod command;
pub mod common;
pub mod configuration;
@@ -37,8 +37,9 @@ use crate::command::request_command::{im_request_command, login_command};
use crate::command::room_member_command::{
cursor_page_room_members, get_room_members, page_room, update_my_room_info,
};
use crate::command::setting_command::{get_settings, update_settings};
use crate::command::user_command::remove_tokens;
use crate::configuration::get_configuration;
use crate::configuration::{Settings, get_configuration};
use crate::error::CommonError;
use crate::repository::im_user_repository;
use sea_orm::DatabaseConnection;
@@ -55,6 +56,7 @@ pub struct AppData {
db_conn: Arc<DatabaseConnection>,
user_info: Arc<Mutex<UserInfo>>,
pub rc: Arc<Mutex<im_request_client::ImRequestClient>>,
pub config: Arc<Mutex<Settings>>,
}
use crate::command::chat_history_command::query_chat_history;
@@ -118,6 +120,7 @@ async fn initialize_app_data(
Arc<DatabaseConnection>,
Arc<Mutex<UserInfo>>,
Arc<Mutex<im_request_client::ImRequestClient>>,
Arc<Mutex<Settings>>,
),
CommonError,
> {
@@ -125,14 +128,16 @@ async fn initialize_app_data(
use tracing::info;
// 加载配置
let configuration = Arc::new(
get_configuration(&app_handle)
.map_err(|e| anyhow::anyhow!("Failed to load configuration: {}", e))?,
);
let configuration =
Arc::new(Mutex::new(get_configuration(&app_handle).map_err(|e| {
anyhow::anyhow!("Failed to load configuration: {}", e)
})?));
// 初始化数据库连接
let db: Arc<DatabaseConnection> = Arc::new(
configuration
.lock()
.await
.database
.connection_string(&app_handle)
.await?,
@@ -148,8 +153,10 @@ async fn initialize_app_data(
}
}
let rc: im_request_client::ImRequestClient =
im_request_client::ImRequestClient::new(configuration.backend.base_url.clone()).unwrap();
let rc: im_request_client::ImRequestClient = im_request_client::ImRequestClient::new(
configuration.lock().await.backend.base_url.clone(),
)
.unwrap();
// 创建用户信息
let user_info = UserInfo {
@@ -159,7 +166,7 @@ async fn initialize_app_data(
};
let user_info = Arc::new(Mutex::new(user_info));
Ok((db, user_info, Arc::new(Mutex::new(rc))))
Ok((db, user_info, Arc::new(Mutex::new(rc)), configuration))
}
// 设置用户信息监听器
@@ -352,6 +359,9 @@ fn setup_mobile() {
// 公共的 setup 函数
fn common_setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
let scope = app.fs_scope();
scope.allow_directory("configuration", false).unwrap();
let app_handle = app.handle().clone();
setup_user_info_listener_early(app.handle().clone());
#[cfg(desktop)]
@@ -359,12 +369,13 @@ fn common_setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>>
// 异步初始化应用数据,避免阻塞主线程
match tauri::async_runtime::block_on(initialize_app_data(app_handle.clone())) {
Ok((db, user_info, rc)) => {
Ok((db, user_info, rc, settings)) => {
// 使用 manage 方法在运行时添加状态
app_handle.manage(AppData {
db_conn: db.clone(),
user_info: user_info.clone(),
rc: rc,
config: settings,
});
}
Err(e) => {
@@ -455,5 +466,7 @@ fn get_invoke_handlers() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Se
ws_get_app_background_state,
login_command,
im_request_command,
get_settings,
update_settings,
]
}
@@ -5,7 +5,7 @@ use sea_orm::prelude::Expr;
use sea_orm::sea_query::Alias;
use sea_orm::{
ColumnTrait, Condition, ConnectionTrait, DatabaseConnection, DatabaseTransaction, EntityTrait,
IntoActiveModel, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set,
IntoActiveModel, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set, TryIntoModel,
};
use tracing::{debug, error, info};
@@ -237,19 +237,45 @@ pub async fn save_message(
/// 更新消息发送状态
pub async fn update_message_status(
db: &DatabaseConnection,
message_id: &str,
message: im_message::Model,
status: &str,
id: Option<String>,
login_uid: String,
) -> Result<(), CommonError> {
) -> Result<im_message::Model, CommonError> {
let mut active_model: im_message::ActiveModel =
im_message::Entity::find_by_id((message_id.to_string(), login_uid))
im_message::Entity::find_by_id((message.id.clone(), login_uid))
.one(db)
.await
.map_err(|e| anyhow::anyhow!("Failed to find message: {}", e))?
.ok_or_else(|| CommonError::UnexpectedError(anyhow::anyhow!("Message not found")))?
.into_active_model();
// 设置 time_block
if message.id.starts_with('T') {
info!("check msg sendTime");
// 查找该房间的最后一条消息
let last_message = im_message::Entity::find()
.filter(im_message::Column::RoomId.eq(&message.room_id))
.filter(im_message::Column::LoginUid.eq(message.login_uid.clone()))
.filter(im_message::Column::Id.ne(&message.id))
.order_by_desc(im_message::Column::Id)
.limit(1)
.one(db)
.await
.map_err(|e| anyhow::anyhow!("Failed to find last message: {}", e))?;
if let Some(last_message) = last_message {
// 比较时间戳
if let (Some(last_send_time), Some(send_time)) =
(last_message.send_time, message.send_time)
{
if send_time - last_send_time >= 1000 * 60 * 15 {
active_model.time_block = Set(Some(send_time - last_send_time));
}
}
}
}
active_model.send_status = Set(status.to_string());
if status == "success" {
@@ -263,11 +289,11 @@ pub async fn update_message_status(
}
im_message::Entity::update_many()
.set(active_model)
.filter(im_message::Column::Id.eq(message_id))
.set(active_model.clone())
.filter(im_message::Column::Id.eq(message.id))
.exec(db)
.await?;
Ok(())
Ok(active_model.try_into_model()?)
}
/// 更新消息撤回状态
+1 -3
View File
@@ -22,8 +22,6 @@ fn get_websocket_client_container() -> &'static Arc<RwLock<Option<WebSocketClien
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InitWsParams {
pub server_url: String,
pub token: Option<String>,
pub client_id: String,
}
@@ -72,7 +70,7 @@ pub async fn ws_init_connection(
let rc = state.rc.lock().await;
let config = WebSocketConfig {
server_url: params.server_url,
server_url: state.config.lock().await.backend.ws_url.clone(),
client_id: params.client_id,
token: rc.token.clone(),
..Default::default()
+1 -1
View File
@@ -105,7 +105,7 @@ const handleItemMouseEnter = (index: number) => {
// 获取当前悬停项的DOM元素
const item =
document.activeElement?.closest('.float-block') || (event?.target as HTMLElement)?.closest('.float-block')
(event?.target as HTMLElement)?.closest('.float-block') || document.activeElement?.closest('.float-block')
if (item) {
// 获取元素相对于列表容器的位置
+76 -45
View File
@@ -2,7 +2,9 @@
<n-flex vertical class="select-none">
<n-flex align="center" justify="space-between" class="color-[--text-color] px-20px py-10px">
<p class="text-16px">{{ props.type === 'friend' ? '好友通知' : '群通知' }}</p>
<svg class="size-18px cursor-pointer"><use href="#delete"></use></svg>
<svg class="size-18px cursor-pointer">
<use href="#delete"></use>
</svg>
</n-flex>
<n-virtual-list
@@ -22,14 +24,20 @@
<n-avatar
round
size="large"
:src="avatarSrc(groupStore.getUserInfo(isCurrentUser(item.uid) ? item.targetId : item.uid)!.avatar!)"
:src="
avatarSrc(
groupStore.getUserInfo(isCurrentUser(item.senderId) ? item.operateId : item.senderId)!.avatar!
)
"
class="mr-10px" />
<n-flex vertical :size="12">
<n-flex align="center" :size="10">
<p
@click="isCurrentUser(item.uid) ? (currentUserId = item.targetId) : (currentUserId = item.uid)"
@click="
isCurrentUser(item.senderId) ? (currentUserId = item.operateId) : (currentUserId = item.senderId)
"
class="text-(14px #13987f) cursor-pointer">
{{ groupStore.getUserInfo(isCurrentUser(item.uid) ? item.targetId : item.uid)!.name }}
{{ groupStore.getUserInfo(isCurrentUser(item.senderId) ? item.operateId : item.senderId)!.name }}
</p>
<p class="text-(14px [--text-color])">
@@ -38,39 +46,53 @@
<p class="text-(10px #909090)">{{ formatTimestamp(item.createTime) }}</p>
</n-flex>
<p v-show="item.msg" class="text-(12px [--text-color])">留言{{ item.msg }}</p>
<p v-show="isFriendApplyOrGroupInvite(item)" class="text-(12px [--text-color])">
留言{{ item.content }}
</p>
</n-flex>
</n-flex>
<n-flex
align="center"
:size="10"
v-if="item.status === RequestFriendAgreeStatus.Waiting && !isCurrentUser(item.uid)">
<n-button secondary :loading="loadingMap[item.applyId]" @click="handleAgree(item.applyId)">接受</n-button>
<n-dropdown
trigger="click"
:options="dropdownOptions"
@select="(key: string) => handleFriendAction(key, item.applyId)">
<n-icon class="cursor-pointer px-6px">
<svg class="size-16px color-[--text-color]"><use href="#more"></use></svg>
</n-icon>
</n-dropdown>
</n-flex>
<span
class="text-(12px #64a29c)"
:class="{ 'text-(12px #c14053)': item.status === RequestFriendAgreeStatus.Reject }"
v-else-if="isCurrentUser(item.uid)">
{{
isAccepted(item.targetId)
? '已同意'
: item.status === RequestFriendAgreeStatus.Reject
? '对方已拒绝'
: '等待验证'
}}
</span>
<span class="text-(12px #64a29c)" v-else-if="item.status === RequestFriendAgreeStatus.Agree">已同意</span>
<span class="text-(12px #c14053)" v-else-if="item.status === RequestFriendAgreeStatus.Reject">已拒绝</span>
<span class="text-(12px #909090)" v-else-if="item.status === RequestFriendAgreeStatus.Ignore">已忽略</span>
<div v-if="isFriendApplyOrGroupInvite(item)">
<n-flex
align="center"
:size="10"
v-if="item.status === RequestNoticeAgreeStatus.UNTREATED && !isCurrentUser(item.senderId)">
<n-button secondary :loading="loadingMap[item.applyId]" @click="handleAgree(item.applyId)">
接受
</n-button>
<n-dropdown
trigger="click"
:options="dropdownOptions"
@select="(key: string) => handleFriendAction(key, item.applyId)">
<n-icon class="cursor-pointer px-6px">
<svg class="size-16px color-[--text-color]">
<use href="#more"></use>
</svg>
</n-icon>
</n-dropdown>
</n-flex>
<span class="text-(12px #64a29c)" v-else-if="item.status === RequestNoticeAgreeStatus.ACCEPTED">
已同意
</span>
<span class="text-(12px #c14053)" v-else-if="item.status === RequestNoticeAgreeStatus.REJECTED">
已拒绝
</span>
<span class="text-(12px #909090)" v-else-if="item.status === RequestNoticeAgreeStatus.IGNORE">
已忽略
</span>
<span
class="text-(12px #64a29c)"
:class="{ 'text-(12px #c14053)': item.status === RequestNoticeAgreeStatus.REJECTED }"
v-else-if="isCurrentUser(item.senderId)">
{{
isAccepted(item)
? '已同意'
: item.status === RequestNoticeAgreeStatus.REJECTED
? '对方已拒绝'
: '等待验证'
}}
</span>
</div>
</n-flex>
</n-flex>
</template>
@@ -78,12 +100,12 @@
<!-- 空数据提示 -->
<n-flex v-if="applyList.length === 0" vertical justify="center" align="center" class="py-40px">
<n-empty description="暂无好友申请" />
<n-empty :description="props.type === 'friend' ? '暂无好友申请' : '暂无群通知'" />
</n-flex>
</n-flex>
</template>
<script setup lang="ts">
import { RequestFriendAgreeStatus } from '@/services/types.ts'
import { NoticeType, RequestNoticeAgreeStatus } from '@/services/types.ts'
import { useContactStore } from '@/stores/contacts.ts'
import { useUserStore } from '@/stores/user'
import { AvatarUtils } from '@/utils/AvatarUtils'
@@ -102,9 +124,8 @@ const props = defineProps<{
}>()
// 检查好友申请是否已被接受
const isAccepted = (targetId: string) => {
// 使用缓存集合快速检查目标用户是否在联系人列表中
return contactStore.contactsList.some((contact) => contact.uid === targetId)
const isAccepted = (item: any) => {
return item.status !== RequestNoticeAgreeStatus.UNTREATED
}
const applyList = computed(() => {
@@ -119,13 +140,13 @@ const applyList = computed(() => {
const applyMsg = computed(() => (item: any) => {
if (props.type === 'friend') {
return isCurrentUser(item.uid)
? isAccepted(item.targetId)
? '已同意你的请求'
: '正在验证你的邀请'
: '请求加为好友'
return isCurrentUser(item.senderId) ? (isAccepted(item) ? '已同意你的请求' : '正在验证你的邀请') : '请求加为好友'
} else {
return isCurrentUser(item.uid) ? '已同意你的邀请' : '请求邀请进入群聊'
if (isFriendApplyOrGroupInvite(item)) {
return isCurrentUser(item.senderId) ? '已同意加入' + item.content : '邀请你加入' + item.content
} else if (item.eventType === NoticeType.GROUP_MEMBER_DELETE) {
return '已被' + groupStore.getUserInfo(item.senderId)!.name + '踢出' + item.content
}
}
})
@@ -148,6 +169,16 @@ const isCurrentUser = (uid: string) => {
return uid === userStore.userInfo!.uid
}
// 判断是否为好友申请或者群申请、群邀请
const isFriendApplyOrGroupInvite = (item: any) => {
return (
item.eventType === NoticeType.FRIEND_APPLY ||
item.eventType === NoticeType.GROUP_INVITE ||
item.eventType === NoticeType.GROUP_INVITE_ME ||
item.eventType === NoticeType.ADD_ME
)
}
// 处理滚动事件
const handleScroll = (e: Event) => {
if (isLoadingMore.value) return
@@ -1,6 +1,6 @@
<template>
<!-- 底部栏 -->
<main class="border-t-(1px solid [--right-chat-footer-line-color]) relative" :style="{ height: `${footerHeight}px` }">
<main class="border-t-(1px solid [--right-chat-footer-line-color]) relative flex justify-center items-center h-full">
<!-- 拖拽手柄 -->
<div class="resize-handle" :class="{ dragging: isDragging }" @mousedown="startDrag">
<div class="resize-indicator"></div>
@@ -15,7 +15,9 @@
</n-flex>
</div>
<div class="size-full relative color-[--icon-color] flex flex-col">
<ChatMsgMultiChoose v-if="chatStore.isMsgMultiChoose" />
<div v-else class="size-full relative color-[--icon-color] flex flex-col">
<!-- 输入框顶部选项栏 -->
<n-flex align="center" justify="space-between" class="p-[10px_22px_5px] select-none flex-shrink-0">
<n-flex align="center" :size="0" class="input-options">
@@ -162,6 +164,7 @@ import { useGlobalShortcut } from '@/hooks/useGlobalShortcut.ts'
import { useMitt } from '@/hooks/useMitt'
import { useWindow } from '@/hooks/useWindow'
import type { ContactItem, FilesMeta, SessionItem } from '@/services/types'
import { useChatStore } from '@/stores/chat'
import { useContactStore } from '@/stores/contacts'
import { useGlobalStore } from '@/stores/global.ts'
import { useHistoryStore } from '@/stores/history'
@@ -176,6 +179,7 @@ const { detailId } = defineProps<{
const globalStore = useGlobalStore()
const contactStore = useContactStore()
const historyStore = useHistoryStore()
const chatStore = useChatStore()
const settingStore = useSettingStore()
const { handleScreenshot } = useGlobalShortcut()
const MsgInputRef = ref()
+9 -21
View File
@@ -20,9 +20,7 @@
[{{ groupStore.countInfo?.memberNum }}]
</p>
</label>
<svg
v-if="activeItem.hotFlag === IsAllUserEnum.Yes && !headerLoading"
class="size-20px color-#13987f select-none outline-none">
<svg v-if="activeItem.hotFlag === IsAllUserEnum.Yes" class="size-20px color-#13987f select-none outline-none">
<use href="#auth"></use>
</svg>
<n-flex v-else-if="activeItem.type === RoomTypeEnum.SINGLE" align="center">
@@ -431,17 +429,15 @@ const tips = ref()
const optionsType = ref<RoomActEnum>()
const modalShow = ref(false)
const sidebarShow = ref(false)
const headerLoading = ref(false)
const cacheStore = useCachedStore()
const { currentSession: activeItem } = storeToRefs(globalStore)
// const activeItem = globalStore.currentSession!
// 是否为频道(仅显示 more 按钮)
const isChannel = computed(() => activeItem.value.hotFlag === IsAllUserEnum.Yes || activeItem.value.roomId === '1')
const isChannel = computed(() => activeItem.value?.hotFlag === IsAllUserEnum.Yes || activeItem.value?.roomId === '1')
// 是否为群主
const isGroupOwner = computed(() => {
// 频道不能修改群头像和群名称
if (activeItem.value.roomId === '1' || activeItem.value.hotFlag === IsAllUserEnum.Yes) {
if (!activeItem.value || activeItem.value.roomId === '1' || activeItem.value.hotFlag === IsAllUserEnum.Yes) {
return false
}
@@ -459,8 +455,8 @@ const groupNameInputRef = useTemplateRef<HTMLInputElement | null>('groupNameInpu
const messageSettingType = computed(() => {
// 群消息设置只在免打扰模式下有意义
if (activeItem.value.muteNotification === NotificationTypeEnum.NOT_DISTURB) {
return activeItem.value.shield ? 'shield' : 'notification'
if (activeItem.value?.muteNotification === NotificationTypeEnum.NOT_DISTURB) {
return activeItem.value?.shield ? 'shield' : 'notification'
}
// 非免打扰模式下,默认返回 notification
return 'notification'
@@ -471,13 +467,14 @@ const messageSettingOptions = ref([
])
/** 是否在线 */
const isOnline = computed(() => {
if (!activeItem.value) return false
if (activeItem.value.type === RoomTypeEnum.GROUP) return true
const contact = contactStore.contactsList.find((item) => item.uid === activeItem.value.detailId)
return contact?.activeStatus === OnlineEnum.ONLINE
})
/** 是否还是好友 */
const shouldShowDeleteFriend = computed(() => {
if (activeItem.value.type === RoomTypeEnum.GROUP) return false
if (!activeItem.value || activeItem.value.type === RoomTypeEnum.GROUP) return false
return contactStore.contactsList.some((item) => item.uid === activeItem.value.detailId)
})
const groupUserList = computed(() => groupStore.userList)
@@ -499,7 +496,7 @@ const userList = computed(() => {
})
/** 获取当前用户的状态信息 */
const currentUserStatus = computed(() => {
if (activeItem.value.type === RoomTypeEnum.GROUP) return null
if (!activeItem.value || activeItem.value.type === RoomTypeEnum.GROUP) return null
// 使用 useUserInfo 获取用户信息
if (!activeItem.value.detailId) return null
@@ -522,6 +519,7 @@ const statusTitle = computed(() => {
// 获取用户的最新头像
const currentUserAvatar = computed(() => {
if (!activeItem.value) return ''
if (activeItem.value.type === RoomTypeEnum.GROUP) {
return AvatarUtils.getAvatarUrl(activeItem.value.avatar)
} else if (activeItem.value.detailId) {
@@ -556,15 +554,6 @@ const {
}
})
// 监听消息加载状态变化
watch(
() => chatStore.currentMessageOptions?.isLoading,
(isLoading) => {
headerLoading.value = isLoading!
},
{ immediate: true }
)
watchEffect(() => {
stream.value?.getVideoTracks()[0]?.addEventListener('ended', () => {
stop()
@@ -903,7 +892,6 @@ const handleVideoCall = async (remotedUid: string, callType: CallTypeEnum) => {
onMounted(() => {
window.addEventListener('click', closeMenu, true)
if (!chatStore.currentMessageOptions?.isLoading) headerLoading.value = false
useMitt.on(WsResponseMessageType.VideoCallRequest, (event) => {
info(`收到通话请求:${JSON.stringify(event)}`)
+84 -73
View File
@@ -49,31 +49,44 @@
<!-- 消息列表 -->
<div class="message-list min-h-full flex flex-col">
<!-- 没有更多消息提示 -->
<div v-show="chatStore.shouldShowNoMoreMessage" class="flex-center gap-6px h-32px flex-shrink-0">
<span class="text-(12px #909090)">没有更多消息</span>
<div
v-show="chatStore.shouldShowNoMoreMessage"
class="flex-center gap-6px h-32px flex-shrink-0 cursor-default select-none">
<p class="text-(12px #909090)">没有更多消息</p>
</div>
<n-flex
v-for="(item, index) in chatStore.chatMessageList"
:key="item.message.id"
vertical
class="flex-y-center"
:class="[
item.message.type === MsgEnum.RECALL ? 'min-h-22px' : 'min-h-62px',
isGroup ? 'p-[14px_10px_14px_20px]' : 'chat-single p-[4px_10px_10px_20px]',
{ 'active-reply': activeReply === item.message.id }
]"
class="flex-y-center mb-12px"
:data-message-id="item.message.id"
:data-message-index="index">
<!-- 信息间隔时间 -->
<span class="text-(12px #909090) select-none p-4px" v-if="item.timeBlock">
{{ item.timeBlock }}
<span class="text-(12px #909090) select-none p-4px" v-if="item.timeBlock" @click.stop>
{{ timeToStr(item.message.sendTime) }}
</span>
<RenderMessage
:message="item"
:is-group="isGroup"
:from-user="{ uid: item.fromUser.uid }"
:upload-progress="item.uploadProgress"
@jump2-reply="jumpToReplyMsg" />
<!-- 消息内容容器 -->
<div
@mouseenter="hoverId = item.message.id"
:class="[
'w-full box-border',
item.message.type === MsgEnum.RECALL ? 'min-h-22px' : 'min-h-62px',
isGroup ? 'p-[14px_10px_14px_20px]' : 'chat-single p-[4px_10px_10px_20px]',
{ 'active-reply': activeReply === item.message.id },
{ 'bg-#90909020': computeMsgHover(item.message.id) }
]"
@click="
() => {
item.isCheck = !item.isCheck
}
">
<RenderMessage
:message="item"
:is-group="isGroup"
:from-user="{ uid: item.fromUser.uid }"
:upload-progress="item.uploadProgress"
@jump2-reply="jumpToReplyMsg" />
</div>
</n-flex>
</div>
</div>
@@ -147,6 +160,7 @@ import { useChatStore } from '@/stores/chat.ts'
import { useGlobalStore } from '@/stores/global'
import { useUserStore } from '@/stores/user.ts'
import { audioManager } from '@/utils/AudioManager'
import { timeToStr } from '@/utils/ComputedTime'
import { isMac, isWindows } from '@/utils/PlatformConstants'
type AnnouncementData = {
@@ -162,8 +176,12 @@ const userStore = useUserStore()
const cachedStore = useCachedStore()
const networkStatus = useNetworkStatus()
const { footerHeight } = useChatLayoutGlobal()
// 添加标记,用于识别是否正在加载历史消息
const isLoadingMore = ref(false)
const { createWebviewWindow } = useWindow()
const { handleConfirm, tips, modalShow, selectKey, scrollTop } = useChatMain()
const { handlePopoverUpdate, enableScroll } = usePopover(selectKey, 'image-chat-main')
provide('popoverControls', { enableScroll })
// 滚动意图状态
const scrollIntent = ref<ScrollIntentEnum>(ScrollIntentEnum.NONE)
@@ -171,26 +189,19 @@ const scrollIntent = ref<ScrollIntentEnum>(ScrollIntentEnum.NONE)
const isGroup = computed<boolean>(() => chatStore.isGroup)
const userUid = computed(() => userStore.userInfo!.uid || '')
const currentNewMsgCount = computed(() => chatStore.currentNewMsgCount || null)
const { createWebviewWindow } = useWindow()
const currentRoomId = computed(() => globalStore.currentSession?.roomId ?? null)
// 响应式状态变量
const activeReply = ref<string>('')
const scrollContainerRef = useTemplateRef<HTMLDivElement>('scrollContainer')
const infoPopoverRefs = shallowRef<Record<string, any>>({})
const showScrollbar = ref<boolean>(true)
// const rafId = ref<number>()
const messageIdToIndexMap = shallowRef<Map<string, number>>(new Map())
const isAnnouncementHover = ref<boolean>(false)
const topAnnouncement = ref<AnnouncementData | null>(null)
const computeMsgHover = computed(() => (id: string) => {
return (
chatStore.isMsgMultiChoose &&
(hoverId.value === id || chatStore.chatMessageList.find((msg) => msg.message.id === id)?.isCheck)
)
})
// CSS 变量计算,避免动态高度重排
const cssVariables = computed(() => {
return {
'--footer-height': `${footerHeight.value}px`
}
})
// 是否显示悬浮页脚
const shouldShowFloatFooter = computed<boolean>(() => {
const container = scrollContainerRef.value
@@ -223,9 +234,19 @@ const shouldShowFloatFooter = computed<boolean>(() => {
return false
})
const { handleConfirm, tips, modalShow, selectKey, scrollTop } = useChatMain()
const { handlePopoverUpdate, enableScroll } = usePopover(selectKey, 'image-chat-main')
provide('popoverControls', { enableScroll })
// 响应式状态变量
const activeReply = ref<string>('')
const scrollContainerRef = useTemplateRef<HTMLDivElement>('scrollContainer')
const infoPopoverRefs = shallowRef<Record<string, any>>({})
const showScrollbar = ref<boolean>(true)
const isAnnouncementHover = ref<boolean>(false)
const topAnnouncement = ref<AnnouncementData | null>(null)
const hoverId = ref('')
// 添加标记,用于识别是否正在加载历史消息
const isLoadingMore = ref(false)
// 监听公告更新和清空事件的变量
let announcementUpdatedListener: any = null
let announcementClearListener: any = null
// 1. 监听房间切换,触发初始化滚动意图
watch(
@@ -251,7 +272,7 @@ watchPostEffect(() => {
// 跳转到回复消息
const jumpToReplyMsg = async (key: string): Promise<void> => {
// 先在当前列表中尝试查找
let messageIndex = chatStore.chatMessageList.findIndex((msg) => msg.message.id === String(key))
let messageIndex = chatStore.chatMessageList.findIndex((msg: any) => msg.message.id === String(key))
// 如果找到了,直接滚动到该消息
if (messageIndex !== -1) {
@@ -345,12 +366,14 @@ const handleScrollByIntent = (intent: ScrollIntentEnum): void => {
// 滚动到底部
const scrollToBottom = (): void => {
// console.log('触发滚动到底部')
const container = scrollContainerRef.value
if (!container) return
// 立即清除新消息计数
chatStore.clearNewMsgCount()
scrollContainerRef.value.scrollTo({
top: scrollContainerRef.value.scrollHeight,
// 使用requestAnimationFrame确保在下一帧执行,避免布局抖动
container.scrollTo({
top: container.scrollHeight,
behavior: 'auto'
})
}
@@ -358,7 +381,10 @@ const scrollToBottom = (): void => {
// 处理悬浮按钮点击 - 重置消息列表并滚动到底部
const handleFloatButtonClick = async () => {
try {
await chatStore.resetAndRefreshCurrentRoomMessages()
// 只有消息数量超过60条才进行重置和刷新
if (chatStore.chatMessageList.length > 60) {
await chatStore.resetAndRefreshCurrentRoomMessages()
}
scrollToBottom()
} catch (error) {
console.error('重置消息列表失败:', error)
@@ -366,14 +392,6 @@ const handleFloatButtonClick = async () => {
}
}
// 更新消息索引映射
const updateMessageIndexMap = (): void => {
messageIdToIndexMap.value.clear()
chatStore.chatMessageList.forEach((msg, index) => {
messageIdToIndexMap.value.set(msg.message.id, index)
})
}
// 处理滚动事件(用于页脚显示功能)
const handleScroll = (event: Event) => {
const container = event.target as HTMLElement
@@ -430,11 +448,8 @@ watch(
// 监听消息列表变化
watch(
chatStore.chatMessageList,
(value, oldValue) => {
// 更新消息索引映射
updateMessageIndexMap()
() => chatStore.chatMessageList,
async (value, oldValue) => {
// 简化消息列表监听,避免直接滚动操作
if (value.length > oldValue.length) {
// 获取最新消息
@@ -448,10 +463,9 @@ watch(
// 新消息计数逻辑(不在底部时)
const container = scrollContainerRef.value
if (container) {
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight
const distanceFromBottom = container.scrollHeight - container.scrollTop
const isOtherUserMessage =
latestMessage?.fromUser?.uid && String(latestMessage.fromUser.uid) !== String(userUid.value)
// 只有当不在底部且是他人消息时才增加计数
if (distanceFromBottom > 300 && isOtherUserMessage) {
const current = chatStore.newMsgCount.get(globalStore.currentSession!.roomId)
@@ -463,6 +477,9 @@ watch(
} else {
current.count++
}
} else {
await nextTick()
scrollToBottom()
}
}
}
@@ -511,9 +528,6 @@ const handleLoadMore = async (): Promise<void> => {
try {
await chatStore.loadMore()
// 更新消息索引映射
updateMessageIndexMap()
// 计算新的滚动位置,保持用户在加载前的相对位置
const newScrollHeight = container.scrollHeight
const heightDifference = newScrollHeight - oldScrollHeight
@@ -573,9 +587,20 @@ const handleViewAnnouncement = (): void => {
})
}
// 监听公告更新和清空事件的变量
let announcementUpdatedListener: any = null
let announcementClearListener: any = null
useMitt.on(`${MittEnum.INFO_POPOVER}-Main`, (event: any) => {
selectKey.value = event.uid
infoPopoverRefs.value[event.uid].setShow(true)
handlePopoverUpdate(event.uid)
})
// 监听滚动到底部的事件
useMitt.on(MittEnum.CHAT_SCROLL_BOTTOM, async () => {
// 只有消息数量超过60条才进行重置和刷新
if (chatStore.chatMessageList.length > 20) {
await chatStore.resetAndRefreshCurrentRoomMessages()
}
scrollToBottom()
})
onMounted(() => {
// 初始化监听器
@@ -614,22 +639,8 @@ onMounted(() => {
initListeners().catch(console.error)
nextTick(() => {
updateMessageIndexMap()
loadTopAnnouncement()
})
useMitt.on(`${MittEnum.INFO_POPOVER}-Main`, (event: any) => {
selectKey.value = event.uid
infoPopoverRefs.value[event.uid].setShow(true)
handlePopoverUpdate(event.uid)
})
// 监听滚动到底部的事件
useMitt.on(MittEnum.CHAT_SCROLL_BOTTOM, () => {
nextTick(() => {
scrollToBottom()
})
})
scrollToBottom()
})
@@ -0,0 +1,221 @@
<template>
<div class="relative flex-center gap-22px h-full">
<!-- 功能模块 -->
<div class="flex flex-col flex-y-center gap-14px" v-for="opt in opts">
<n-button :disabled="opt.disabled" secondary circle @click="opt.click" class="size-46px">
<template #icon>
<svg class="size-22px">
<use :href="opt.icon"></use>
</svg>
</template>
</n-button>
<p class="text-(12px [--text-color])">{{ opt.text }}</p>
</div>
</div>
<n-modal v-model:show="showModal" class="w-70% rounded-8px">
<div class="bg-[--bg-popover] h-full p-6px box-border flex flex-col">
<div
v-if="isMac()"
@click="showModal = false"
class="mac-close z-999 size-13px shadow-inner bg-#ed6a5eff rounded-50% select-none absolute left-6px">
<svg class="hidden size-7px color-#000 select-none absolute top-3px left-3px">
<use href="#close"></use>
</svg>
</div>
<svg v-if="isWindows()" @click="showModal = false" class="size-12px ml-a cursor-pointer select-none">
<use href="#close"></use>
</svg>
<div class="pt-20px flex flex-col select-none">
<div class="flex flex-row">
<!-- 搜索会话 -->
<div class="flex-1 h-64vh bg-#f3f3f3 dark:bg-#222 rounded-8px px-12px pt-12px flex flex-col">
<n-input
id="search"
v-model:value="searchText"
class="rounded-6px w-full relative text-12px"
style="background: var(--search-bg-color)"
:maxlength="20"
clearable
size="small">
<template #prefix>
<svg class="w-12px h-12px">
<use href="#search"></use>
</svg>
</template>
</n-input>
<n-scrollbar class="flex-1">
<template v-for="session in chatStore.sessionList" :key="session.roomId">
<n-flex align="center" :size="8" class="text-(12px #909090) py-8px px-4px">
<n-checkbox v-model:checked="session.isCheck" @click.stop />
<n-avatar class="rounded-8px" :size="30" :src="AvatarUtils.getAvatarUrl(session.avatar)" />
<p>{{ session.remark ? session.remark : session.name }}</p>
<p v-if="session.type === RoomTypeEnum.GROUP">
({{ groupStore.getGroupDetailByRoomId(session.roomId)?.memberNum }})
</p>
</n-flex>
</template>
</n-scrollbar>
</div>
<!-- 已选择会话 -->
<div class="flex-1 min-w-0 h-64vh px-12px flex flex-col">
<p class="text-(12px #909090) pb-10px">分别发送给</p>
<n-scrollbar class="flex-1">
<template v-for="session in selectedSessions" :key="session.roomId">
<n-flex align="center" class="p-8px">
<n-avatar class="rounded-8px" :size="30" :src="AvatarUtils.getAvatarUrl(session.avatar)" />
<p class="text-(12px [--chat-text-color])">{{ session.remark ? session.remark : session.name }}</p>
<n-button quaternary circle size="small" class="ml-auto" @click="handleRemoveSession(session.roomId)">
<template #icon>
<n-icon size="14">
<svg class="w-14px h-14px">
<use href="#close"></use>
</svg>
</n-icon>
</template>
</n-button>
</n-flex>
</template>
</n-scrollbar>
<span class="w-full h-1px bg-[--line-color] my-8px"></span>
<div class="flex-1 flex flex-col">
<ChatMultiMsg :content-list="msgContents" :msg-ids="msgIds" />
<n-input class="my-12px" placeholder="给朋友留言" />
<div class="flex justify-between">
<n-button quaternary class="w-100px h-30px" @click="showModal = false">取消</n-button>
<n-button secondary type="primary" class="w-100px h-30px" @click="sendMsg">发送</n-button>
</div>
</div>
</div>
</div>
</div>
</div>
</n-modal>
</template>
<script setup lang="ts">
import { MergeMessageType, RoomTypeEnum } from '@/enums'
import { useChatStore } from '@/stores/chat'
import { useGlobalStore } from '@/stores/global'
import { useGroupStore } from '@/stores/group'
import { AvatarUtils } from '@/utils/AvatarUtils'
import { mergeMsg } from '@/utils/ImRequestUtils'
import { isMac, isWindows } from '@/utils/PlatformConstants'
import type { MsgId } from '~/src/typings/global'
import ChatMultiMsg from './ChatMultiMsg.vue'
const chatStore = useChatStore()
const groupStore = useGroupStore()
const globalStore = useGlobalStore()
const showModal = ref(false)
const searchText = ref('')
const selectedSessions = computed(() => {
return chatStore.sessionList.filter((session) => session.isCheck === true)
})
const selectedMsgs = computed(() => {
return chatStore.chatMessageList.filter((msg) => msg.isCheck === true)
})
const msgContents = computed(() => {
return selectedMsgs.value.map((msg) => {
const userInfo = groupStore.getUserInfo(msg.fromUser.uid)
return userInfo?.name + ': ' + msg.message.body.content
})
})
const msgIds = computed((): MsgId[] => {
return selectedMsgs.value.map((msg) => {
return {
msgId: msg.message.id,
fromUid: msg.fromUser.uid
}
})
})
let mergeMessageType = MergeMessageType.SINGLE
const opts = computed(() => [
{
text: '逐条转发',
icon: '#share-three',
disabled: selectedMsgs.value.length === 0,
click: () => {
console.log(selectedMsgs)
}
},
{
text: '合并转发',
icon: '#share',
disabled: selectedMsgs.value.length === 0,
click: () => {
if (selectedMsgs.value.length > 0) {
showModal.value = true
mergeMessageType = MergeMessageType.MERGE
}
}
},
{
text: '收藏',
icon: '#collect'
},
{
text: '保存至电脑',
icon: '#collect-laptop'
},
{
text: '删除',
icon: '#delete'
},
{
text: '',
icon: '#close',
click: () => {
chatStore.chatMessageList.forEach((msg) => (msg.isCheck = false))
chatStore.setMsgMultiChoose(false)
}
}
])
const handleRemoveSession = (roomId: string) => {
const session = chatStore.sessionList.find((session) => session.roomId === roomId)
if (session) {
session.isCheck = false
}
}
const sendMsg = async () => {
console.log('发送合并消息:', mergeMessageType)
const selectedRoomIds = selectedSessions.value.map((item) => item.roomId)
const selectedMsgIds = selectedMsgs.value.map((item) => item.message.id)
await mergeMsg({
roomIds: selectedRoomIds,
type: mergeMessageType,
messageIds: selectedMsgIds,
fromRoomId: globalStore.currentSessionRoomId
})
.then(() => {
window.$message.success('消息转发成功')
})
.catch((e) => {
console.error('消息转发失败', e)
window.$message.error(e)
})
.finally(() => {
showModal.value = false
chatStore.clearSessionCheck()
chatStore.setMsgMultiChoose(false)
})
}
</script>
<style scoped>
/**! 修改naive-ui复选框的样式 */
:deep(.n-checkbox .n-checkbox-box) {
border-radius: 50%;
width: 16px;
height: 16px;
}
</style>
@@ -0,0 +1,55 @@
<template>
<div class="border-solid border-2 border-#F9F9F9 p-2" @click="openMultiMsgWindow">
<p class="font-600 text-size-14px mb-2">群聊的聊天记录</p>
<div class="max-h-15 overflow-hidden">
<p v-for="content in contentList" class="text-size-14px text-#A2A2A2 line-height-normal truncate">
{{ content }}
</p>
</div>
<n-divider class="!my-2" />
<p class="text-size-14px text-#A2A2A2">聊天记录</p>
</div>
</template>
<script setup lang="ts">
import { useWindow } from '@/hooks/useWindow'
import type { MsgId } from '@/typings/global'
const props = defineProps<{
contentList: string[]
msgIds: MsgId[]
}>()
const { contentList, msgIds } = toRefs(props)
const { createWindow, sendWindowPayload } = useWindow()
const openMultiMsgWindow = async () => {
const label = 'multiMsg--' + Date.now()
try {
// 创建窗口
await createWindow({
title: '聊天记录',
label: label,
width: 800,
height: 600,
wantCloseWindow: undefined,
resizable: true,
minW: 600,
minH: 400,
transparent: false,
visible: true,
queryParams: {
key: label
}
})
// 向窗口发送消息数据
await sendWindowPayload(label, msgIds.value)
} catch (e) {
console.error('创建合并消息窗口失败:', e)
window.$message?.error('打开聊天记录失败')
}
}
</script>
<style scoped lang="scss"></style>
@@ -254,6 +254,10 @@ const filteredUserList = computed(() => {
})
}
return userList.sort((a, b) => {
if (a.roleId && b.roleId && a.roleId !== b.roleId) {
return a.roleId - b.roleId
}
if (a.activeStatus !== b.activeStatus) {
return a.activeStatus - b.activeStatus
}
+5 -12
View File
@@ -2,13 +2,14 @@
<!-- 头部 -->
<ChatHeader />
<n-flex :class="{ 'shadow-inner': page.shadow }" :size="0" class="h-full">
<div class="flex flex-col flex-1 relative min-h-0">
<div class="flex-col flex-1 relative">
<!-- 中间聊天框内容 -->
<div class="flex-1 min-h-0 relative">
<div class="max-h-80%">
<ChatMain />
</div>
<!-- 输入框和操作列表 -->
<ChatFooter :detail-id="globalStore.currentSession!.detailId" />
<ChatFooter class="max-h-20%" :detail-id="globalStore.currentSession!.detailId" />
</div>
<!-- 右侧栏占位群聊时预留宽度直至 Sidebar 挂载完成随后由子组件控制宽度含折叠 -->
<ChatSidebar />
@@ -22,12 +23,4 @@ const settingStore = useSettingStore()
const { page } = storeToRefs(settingStore)
const globalStore = useGlobalStore()
</script>
<style scoped lang="scss">
/**! 修改naive-ui虚拟列表滚动条的间距 */
:deep(
.n-scrollbar > .n-scrollbar-rail.n-scrollbar-rail--vertical,
.n-scrollbar + .n-scrollbar-rail.n-scrollbar-rail--vertical
) {
right: 0;
}
</style>
<style scoped lang="scss"></style>
@@ -40,13 +40,14 @@
import { MsgEnum } from '@/enums/index'
import { useImageViewer } from '@/hooks/useImageViewer'
import type { ImageBody } from '@/services/types'
import { isMobile } from '@/utils/PlatformConstants'
const props = defineProps<{
body: ImageBody
onImageClick?: (url: string) => void
}>()
// 图片显示相关常量
const MAX_WIDTH = 320
const MAX_WIDTH = isMobile() ? 240 : 320
const MAX_HEIGHT = 240
const MIN_WIDTH = 60
const MIN_HEIGHT = 60
@@ -0,0 +1,29 @@
<template>
<ChatMultiMsg :content-list="body.content" :msg-ids="msgIds" />
</template>
<script setup lang="ts">
import type { MergeBody } from '@/services/types'
import type { MsgId } from '@/typings/global'
const props = defineProps<{
body: MergeBody
}>()
const { body } = toRefs(props)
const msgIds = computed((): MsgId[] => {
return body.value.body.map((item) => {
return {
msgId: item.messageId,
fromUid: item.uid
}
})
})
onMounted(() => {
// console.log(props.body.body)
})
</script>
<style scoped lang="scss"></style>
+24 -20
View File
@@ -9,26 +9,30 @@
{{ item }}
</span>
<template v-else-if="item.startsWith('http')">
<n-tooltip trigger="hover">
<template #trigger>
<svg class="size-12px cursor-pointer pr-4px" @click="handleCopy(item)">
<use href="#copy"></use>
</svg>
</template>
<span>复制网址</span>
</n-tooltip>
<n-highlight
v-if="props.searchKeyword"
:text="item"
:patterns="[props.searchKeyword]"
:highlight-style="{
userSelect: 'text',
padding: '2px 4px',
borderRadius: '6px',
color: '#000',
background: '#13987f'
}" />
<p v-else>{{ item }}</p>
<n-flex align="center" :wrap="false">
<n-tooltip trigger="hover" style="flex-shrink: 0">
<template #trigger>
<svg class="size-12px cursor-pointer pr-4px" @click="handleCopy(item)">
<use href="#copy"></use>
</svg>
</template>
<span>复制网址</span>
</n-tooltip>
<div style="flex: 1; word-wrap: break-word; overflow-wrap: anywhere">
<n-highlight
v-if="props.searchKeyword"
:text="item"
:patterns="[props.searchKeyword]"
:highlight-style="{
userSelect: 'text',
padding: '2px 4px',
borderRadius: '6px',
color: '#000',
background: '#13987f'
}" />
<p v-else style="margin: 0">{{ item }}</p>
</div>
</n-flex>
</template>
<n-highlight
v-else-if="props.searchKeyword"
+228 -222
View File
@@ -18,244 +18,246 @@
<!-- 信息时间(单聊) -->
<div
v-if="!isGroup"
class="text-(12px #909090) h-12px w-fit select-none"
:class="
isMe
? activeReply === message.message.id
? 'pr-68px'
: 'pr-42px'
: activeReply === message.message.id
? 'pl-68px'
: 'pl-42px'
">
class="text-(12px #909090) h-12px flex select-none"
:class="{
'pr-48px justify-end': isMe,
'pl-42px justify-start': !isMe
}">
<Transition name="fade-single">
<span v-if="hoverMsgId === message.message.id">
{{ formatTimestamp(message.message.sendTime, true) }}
</span>
</Transition>
</div>
<div class="flex items-start flex-1 select-none" :class="isMe ? 'flex-row-reverse' : ''">
<!-- 回复消息提示的箭头 -->
<svg
v-if="activeReply === message.message.id"
class="size-16px pt-4px color-#909090"
:class="isMe ? 'ml-8px' : 'mr-8px'">
<use :href="isMe ? `#corner-down-left` : `#corner-down-right`"></use>
</svg>
<!-- -->
<n-popover
:ref="(el: any) => (infoPopoverRefs[message.message.id] = el)"
@update:show="handlePopoverUpdate(message.message.id, $event)"
trigger="click"
placement="right"
:show-arrow="false"
style="padding: 0; background: var(--bg-info)">
<template #trigger>
<ContextMenu
@select="$event.click(message, 'Main')"
:content="message"
:menu="isGroup ? optionsList : void 0"
:special-menu="report">
<!-- 存在头像时候显示 -->
<n-avatar
round
:size="34"
@click="selectKey = message.message.id"
class="select-none"
:color="themes.content === ThemeEnum.DARK ? '' : '#fff'"
:fallback-src="themes.content === ThemeEnum.DARK ? '/logoL.png' : '/logoD.png'"
:src="getAvatarSrc(message.fromUser.uid)"
:class="isMe ? '' : 'mr-10px'" />
</ContextMenu>
</template>
<!-- 用户个人信息框 -->
<InfoPopover v-if="selectKey === message.message.id" :uid="fromUser.uid" />
</n-popover>
<div class="flex justify-center items-center">
<n-checkbox
v-model:checked="message.isCheck"
v-if="chatStore.isMsgMultiChoose"
class="mr-3 select-none"
:focusable="false"
@click.stop />
<div class="flex items-start flex-1 select-none" :class="isMe ? 'flex-row-reverse' : ''">
<!-- 回复消息提示的箭 -->
<svg
v-if="activeReply === message.message.id"
class="size-16px pt-4px color-#909090"
:class="isMe ? 'ml-8px' : 'mr-8px'">
<use :href="isMe ? `#corner-down-left` : `#corner-down-right`"></use>
</svg>
<!-- 头像 -->
<n-popover
:ref="(el: any) => (infoPopoverRefs[message.message.id] = el)"
@update:show="handlePopoverUpdate(message.message.id, $event)"
trigger="click"
placement="right"
:show-arrow="false"
style="padding: 0; background: var(--bg-info)">
<template #trigger>
<ContextMenu
@select="$event.click(message, 'Main')"
:content="message"
:menu="isGroup ? optionsList : void 0"
:special-menu="report">
<!-- 存在头像时候显示 -->
<n-avatar
round
:size="34"
@click="selectKey = message.message.id"
class="select-none"
:color="themes.content === ThemeEnum.DARK ? '' : '#fff'"
:fallback-src="themes.content === ThemeEnum.DARK ? '/logoL.png' : '/logoD.png'"
:src="getAvatarSrc(message.fromUser.uid)"
:class="isMe ? '' : 'mr-10px'" />
</ContextMenu>
</template>
<!-- 用户个人信息框 -->
<InfoPopover v-if="selectKey === message.message.id" :uid="fromUser.uid" />
</n-popover>
<n-flex
vertical
justify="center"
:size="6"
class="color-[--text-color] flex-1 select-none"
:class="isMe ? 'items-end mr-10px' : ''">
<n-flex :size="6" align="center" :style="isMe ? 'flex-direction: row-reverse' : ''">
<ContextMenu
@select="$event.click(message, 'Main')"
:content="message"
:menu="isGroup ? optionsList : void 0"
:special-menu="report">
<n-flex
:size="6"
class="select-none cursor-default"
align="center"
v-if="isGroup"
:style="isMe ? 'flex-direction: row-reverse' : ''">
<!-- 用户徽章 -->
<n-popover
v-if="
globalStore.currentSession?.roomId === '1' &&
useBadgeInfo(groupStore.getUserInfo(fromUser.uid)?.wearingItemId).value.img
"
trigger="hover">
<template #trigger>
<n-avatar
class="select-none"
:size="18"
round
:fallback-src="themes.content === ThemeEnum.DARK ? '/logoL.png' : '/logoD.png'"
:src="useBadgeInfo(groupStore.getUserInfo(fromUser.uid)?.wearingItemId).value.img" />
</template>
<span>
{{ useBadgeInfo(groupStore.getUserInfo(fromUser.uid)?.wearingItemId).value.describe }}
<n-flex
vertical
:size="6"
class="color-[--text-color] flex-1 select-none"
:class="isMe ? 'items-end mr-10px' : ''">
<n-flex :size="6" align="center" :style="isMe ? 'flex-direction: row-reverse' : ''">
<ContextMenu
@select="$event.click(message, 'Main')"
:content="message"
:menu="isGroup ? optionsList : void 0"
:special-menu="report">
<n-flex
:size="6"
class="select-none cursor-default"
align="center"
v-if="isGroup"
:style="isMe ? 'flex-direction: row-reverse' : ''">
<!-- 用户徽章 -->
<n-popover
v-if="
globalStore.currentSession?.roomId === '1' &&
useBadgeInfo(groupStore.getUserInfo(fromUser.uid)?.wearingItemId).value.img
"
trigger="hover">
<template #trigger>
<n-avatar
class="select-none"
:size="18"
round
:fallback-src="themes.content === ThemeEnum.DARK ? '/logoL.png' : '/logoD.png'"
:src="useBadgeInfo(groupStore.getUserInfo(fromUser.uid)?.wearingItemId).value.img" />
</template>
<span>
{{ useBadgeInfo(groupStore.getUserInfo(fromUser.uid)?.wearingItemId).value.describe }}
</span>
</n-popover>
<!-- 用户名 -->
<span class="text-12px select-none color-#909090 inline-block align-top">
{{ groupStore.getUserDisplayName(fromUser.uid) }}
</span>
</n-popover>
<!-- 用户名 -->
<span class="text-12px select-none color-#909090 inline-block align-top">
{{ groupStore.getUserDisplayName(fromUser.uid) }}
</span>
<!-- 消息归属地 -->
<span v-if="groupStore.getUserInfo(fromUser.uid)?.locPlace" class="text-(12px #909090)">
({{ groupStore.getUserInfo(fromUser.uid)?.locPlace }})
</span>
</n-flex>
</ContextMenu>
<!-- 群主 -->
<div
v-if="groupStore.isCurrentLord(fromUser.uid)"
class="flex px-4px py-3px rounded-4px bg-#d5304f30 size-fit select-none">
<span class="text-(9px #d5304f)">群主</span>
</div>
<!-- 管理员 -->
<div
v-if="groupStore.isAdmin(fromUser.uid)"
class="flex px-4px py-3px rounded-4px bg-#1a7d6b30 size-fit select-none">
<span class="text-(9px #008080)">管理员</span>
</div>
<!-- 信息时间(群聊) -->
<Transition name="fade-group">
<span v-if="isGroup && hoverMsgId === message.message.id" class="text-(12px #909090) select-none">
{{ formatTimestamp(message.message.sendTime, true) }}
</span>
</Transition>
</n-flex>
<!-- 气泡样式 -->
<ContextMenu
:content="message"
@contextmenu="handleMacSelect"
@mouseenter="() => (hoverMsgId = message.message.id)"
@mouseleave="() => (hoverMsgId = '')"
class="w-fit relative flex flex-col"
:data-key="isMe ? `U${message.message.id}` : `Q${message.message.id}`"
:class="isMe ? 'items-end' : 'items-start'"
:style="{ '--bubble-max-width': isGroup ? '32vw' : '50vw' }"
@select="$event.click(message)"
:menu="handleItemType(message.message.type)"
:emoji="emojiList"
:special-menu="specialMenuList(message.message.type)"
@reply-emoji="handleEmojiSelect($event, message)"
@click="handleMsgClick(message)">
<component
:class="[
message.message.type === MsgEnum.VOICE ? 'select-none cursor-pointer' : 'select-text cursor-text',
!isSpecialMsgType(message.message.type) ? (isMe ? 'bubble-oneself' : 'bubble') : '',
{
active:
activeBubble === message.message.id &&
!isSpecialMsgType(message.message.type) &&
message.message.type !== MsgEnum.VOICE
}
]"
:is="componentMap[message.message.type]"
:body="message.message.body"
:message-status="message.message.status"
:upload-progress="uploadProgress"
:from-user-uid="fromUser?.uid"
:message="message.message"
:data-message-id="message.message.id"
:is-group="isGroup"
:on-image-click="onImageClick"
:on-video-click="onVideoClick"
:search-keyword="searchKeyword" />
<!-- 显示翻译文本 -->
<Transition name="fade-translate" appear mode="out-in">
<!-- 消息归属地 -->
<span v-if="groupStore.getUserInfo(fromUser.uid)?.locPlace" class="text-(12px #909090)">
({{ groupStore.getUserInfo(fromUser.uid)?.locPlace }})
</span>
</n-flex>
</ContextMenu>
<!-- 群主 -->
<div
v-if="message.message.body.translatedText"
class="translated-text select-none cursor-default flex flex-col"
:class="[isMe ? 'item-end' : 'item-start']">
<n-flex align="center" justify="space-between" class="mb-6px">
<n-flex align="center" :size="4">
<span class="text-(12px #909090)">{{ message.message.body.translatedText.provider }}</span>
<svg class="size-12px">
<use href="#success"></use>
v-if="groupStore.isCurrentLord(fromUser.uid)"
class="flex px-4px py-3px rounded-4px bg-#d5304f30 size-fit select-none">
<span class="text-(9px #d5304f)">群主</span>
</div>
<!-- 管理员 -->
<div
v-if="groupStore.isAdmin(fromUser.uid)"
class="flex px-4px py-3px rounded-4px bg-#1a7d6b30 size-fit select-none">
<span class="text-(9px #008080)">管理员</span>
</div>
<!-- 信息时间(群聊) -->
<Transition name="fade-group">
<span v-if="isGroup && hoverMsgId === message.message.id" class="text-(12px #909090) select-none">
{{ formatTimestamp(message.message.sendTime, true) }}
</span>
</Transition>
</n-flex>
<!-- 气泡样式 -->
<ContextMenu
:content="message"
@contextmenu="handleMacSelect"
@mouseenter="() => (hoverMsgId = message.message.id)"
@mouseleave="() => (hoverMsgId = '')"
class="w-fit relative flex flex-col chat-message-max-width"
:data-key="isMe ? `U${message.message.id}` : `Q${message.message.id}`"
:class="isMe ? 'items-end' : 'items-start'"
:style="{ '--bubble-max-width': isGroup ? '32vw' : '50vw' }"
@select="$event.click(message)"
:menu="handleItemType(message.message.type)"
:emoji="emojiList"
:special-menu="specialMenuList(message.message.type)"
@reply-emoji="handleEmojiSelect($event, message)"
@click="handleMsgClick(message)">
<component
:class="[
message.message.type === MsgEnum.VOICE ? 'select-none cursor-pointer' : 'select-text cursor-text',
!isSpecialMsgType(message.message.type) ? (isMe ? 'bubble-oneself' : 'bubble') : '',
{
active:
activeBubble === message.message.id &&
!isSpecialMsgType(message.message.type) &&
message.message.type !== MsgEnum.VOICE
}
]"
:is="componentMap[message.message.type]"
:body="message.message.body"
:message-status="message.message.status"
:upload-progress="uploadProgress"
:from-user-uid="fromUser?.uid"
:message="message.message"
:data-message-id="message.message.id"
:is-group="isGroup"
:on-image-click="onImageClick"
:on-video-click="onVideoClick"
:search-keyword="searchKeyword" />
<!-- 显示翻译文本 -->
<Transition name="fade-translate" appear mode="out-in">
<div
v-if="message.message.body.translatedText"
class="translated-text select-none cursor-default flex flex-col"
:class="[isMe ? 'item-end' : 'item-start']">
<n-flex align="center" justify="space-between" class="mb-6px">
<n-flex align="center" :size="4">
<span class="text-(12px #909090)">{{ message.message.body.translatedText.provider }}</span>
<svg class="size-12px">
<use href="#success"></use>
</svg>
</n-flex>
<svg class="size-10px cursor-pointer" @click="message.message.body.translatedText = null">
<use href="#close"></use>
</svg>
</n-flex>
<svg class="size-10px cursor-pointer" @click="message.message.body.translatedText = null">
<use href="#close"></use>
<p class="select-text cursor-text">{{ message.message.body.translatedText.text }}</p>
</div>
</Transition>
<!-- 消息状态指示器 -->
<div v-if="isMe" class="absolute -left-6 top-2">
<n-icon v-if="message.message.status === MessageStatusEnum.SENDING" class="text-gray-400">
<img class="size-16px" src="@/assets/img/loading-one.svg" alt="" />
</n-icon>
<n-icon
v-if="message.message.status === MessageStatusEnum.FAILED"
class="text-#d5304f cursor-pointer"
@click.stop="handleRetry(message)">
<svg class="size-16px">
<use href="#cloudError"></use>
</svg>
</n-flex>
<p class="select-text cursor-text">{{ message.message.body.translatedText.text }}</p>
</n-icon>
</div>
</Transition>
</ContextMenu>
<!-- 消息状态指示器 -->
<div v-if="isMe" class="absolute -left-6 top-2">
<n-icon v-if="message.message.status === MessageStatusEnum.SENDING" class="text-gray-400">
<img class="size-16px" src="@/assets/img/loading-one.svg" alt="" />
</n-icon>
<n-icon
v-if="message.message.status === MessageStatusEnum.FAILED"
class="text-#d5304f cursor-pointer"
@click.stop="handleRetry(message)">
<svg class="size-16px">
<use href="#cloudError"></use>
</svg>
</n-icon>
</div>
</ContextMenu>
<!-- 回复的内容 -->
<n-flex
align="center"
:size="6"
v-if="message.message.body.reply"
@click="emit('jump2Reply', message.message.body.reply.id)"
class="reply-bubble relative w-fit custom-shadow select-none chat-message-max-width">
<svg class="size-14px">
<use href="#to-top"></use>
</svg>
<n-avatar
class="reply-avatar"
round
:size="20"
:color="themes.content === ThemeEnum.DARK ? '' : '#fff'"
:fallback-src="themes.content === ThemeEnum.DARK ? '/logoL.png' : '/logoD.png'"
:src="getAvatarSrc(message.message.body.reply.uid)" />
<span>{{ `${message.message.body.reply.username}: ` }}</span>
<span class="content-span">
{{ message.message.body.reply.body }}
</span>
<div v-if="message.message.body.reply.imgCount" class="reply-img-sub">
{{ message.message.body.reply.imgCount }}
</div>
</n-flex>
<!-- 回复的内容 -->
<n-flex
align="center"
:size="6"
v-if="message.message.body.reply"
@click="emit('jump2Reply', message.message.body.reply.id)"
class="reply-bubble relative w-fit custom-shadow select-none">
<svg class="size-14px">
<use href="#to-top"></use>
</svg>
<n-avatar
class="reply-avatar"
round
:size="20"
:color="themes.content === ThemeEnum.DARK ? '' : '#fff'"
:fallback-src="themes.content === ThemeEnum.DARK ? '/logoL.png' : '/logoD.png'"
:src="getAvatarSrc(message.message.body.reply.uid)" />
<span>{{ `${message.message.body.reply.username}: ` }}</span>
<span class="content-span">
{{ message.message.body.reply.body }}
</span>
<div v-if="message.message.body.reply.imgCount" class="reply-img-sub">
{{ message.message.body.reply.imgCount }}
<!-- 动态渲染所有回复表情反应 -->
<div
v-if="message.message"
class="flex-y-center gap-6px flex-wrap w-270px"
:class="{ 'justify-end': isSingleLineEmojis(message) }">
<template v-for="emoji in emojiList" :key="emoji.value">
<!-- 根据表情类型获取对应的计数属性名 -->
<div class="flex-y-center" v-if="message && getEmojiCount(message, emoji.value) > 0">
<div class="emoji-reply-bubble" @click.stop="message && cancelReplyEmoji(message, emoji.value)">
<img :title="emoji.title" class="size-18px" :src="emoji.url" :alt="emoji.title" />
<span class="text-(12px #eee)">{{ message ? getEmojiCount(message, emoji.value) : 0 }}</span>
</div>
</div>
</template>
</div>
</n-flex>
<!-- 动态渲染所有回复表情反应 -->
<div
v-if="message.message"
class="flex-y-center gap-6px flex-wrap w-270px"
:class="{ 'justify-end': isSingleLineEmojis(message) }">
<template v-for="emoji in emojiList" :key="emoji.value">
<!-- 根据表情类型获取对应的计数属性名 -->
<div class="flex-y-center" v-if="message && getEmojiCount(message, emoji.value) > 0">
<div class="emoji-reply-bubble" @click.stop="message && cancelReplyEmoji(message, emoji.value)">
<img :title="emoji.title" class="size-18px" :src="emoji.url" :alt="emoji.title" />
<span class="text-(12px #eee)">{{ message ? getEmojiCount(message, emoji.value) : 0 }}</span>
</div>
</div>
</template>
</div>
</n-flex>
</div>
</div>
</div>
</template>
@@ -272,6 +274,7 @@ import { useGroupStore } from '@/stores/group'
import { useSettingStore } from '@/stores/setting'
import { AvatarUtils } from '@/utils/AvatarUtils'
import { formatTimestamp } from '@/utils/ComputedTime.ts'
import { useChatStore } from '~/src/stores/chat'
import { useUserStore } from '~/src/stores/user'
import { markMsg } from '~/src/utils/ImRequestUtils'
import { isMac } from '~/src/utils/PlatformConstants'
@@ -280,6 +283,7 @@ import AudioCall from './AudioCall.vue'
import Emoji from './Emoji.vue'
import File from './File.vue'
import Image from './Image.vue'
import MergeMessage from './MergeMessage.vue'
import BotMessage from './special/BotMessage.vue'
import RecallMessage from './special/RecallMessage.vue'
import SystemMessage from './special/SystemMessage.vue'
@@ -320,6 +324,7 @@ const settingStore = useSettingStore()
const { themes } = storeToRefs(settingStore)
const { optionsList, report, activeBubble, handleItemType, emojiList, specialMenuList, handleMsgClick } = useChatMain()
const groupStore = useGroupStore()
const chatStore = useChatStore()
const recordEL = ref<HTMLElement>()
// 获取用户头像
@@ -340,7 +345,8 @@ const componentMap: Partial<Record<MsgEnum, Component>> = {
[MsgEnum.AUDIO_CALL]: AudioCall,
[MsgEnum.SYSTEM]: SystemMessage,
[MsgEnum.RECALL]: RecallMessage,
[MsgEnum.BOT]: BotMessage
[MsgEnum.BOT]: BotMessage,
[MsgEnum.MERGE]: MergeMessage
}
const isSpecialMsgType = (type: number): boolean => {
@@ -1,18 +1,22 @@
<template>
<!-- 消息为机器人消息时 -->
<div
class="dark:bg-[#fbb99020] bg-[#fbb99030] dark:border-(1px solid #fbb99020) border-(1px solid #fbb99040) flex-center gap-8px px-12px py-4px rounded-8px">
<n-avatar class="select-none" round :size="22" :src="getAvatarSrc(fromUserUid)" />
<main class="w-full flex-center">
<div
v-for="(part, index) in parseMessage(body.content)"
:key="index"
class="text-(12px #fbb990) select-none cursor-default">
<p v-if="part.type === 'text'">{{ part.text }}</p>
<p v-else-if="part.type === 'bracket'" class="text-#13987f">{{ part.text }}</p>
<p v-else-if="part.type === 'number'" class="text-#fbb160">{{ part.text }}</p>
class="chat-message-max-width dark:bg-[#fbb99020] bg-[#fbb99030] dark:border-(1px solid #fbb99020) border-(1px solid #fbb99040) flex-center chat-bot-message-gap px-12px py-4px rounded-8px">
<n-avatar class="select-none" round :size="22" :src="getAvatarSrc(fromUserUid)" />
<div
v-for="(part, index) in parseMessage(body.content)"
:key="index"
class="text-(12px #fbb990) select-none cursor-default">
<p v-if="part.type === 'text'">{{ part.text }}</p>
<p v-else-if="part.type === 'bracket'" class="text-#13987f truncate max-w-20">
{{ part.text }}
</p>
<p v-else-if="part.type === 'number'" class="text-#fbb160">{{ part.text }}</p>
</div>
<img class="size-18px" src="/emoji/party-popper.webp" alt="" />
</div>
<img class="size-18px" src="/emoji/party-popper.webp" alt="" />
</div>
</main>
</template>
<script setup lang="ts">
@@ -1,6 +1,6 @@
<template>
<!-- 消息为撤回消息 -->
<div>
<main class="w-full flex-center">
<template v-if="isGroup">
<n-flex align="center" :size="6" v-if="fromUserUid === userUid">
<p class="text-(12px #909090) select-none cursor-default">你撤回了一条消息</p>
@@ -26,7 +26,7 @@
</p>
</n-flex>
</template>
</div>
</main>
</template>
<script setup lang="ts">
@@ -1,8 +1,10 @@
<template>
<!-- 系统消息 -->
<div class="flex-col-y-center h-62px select-none cursor-default text-(12px #909090)">
{{ props.body }}
</div>
<main class="w-full flex-center h-62px">
<p class="select-none cursor-default text-(12px #909090)">
{{ props.body }}
</p>
</main>
</template>
<script setup lang="ts">
+14 -2
View File
@@ -101,7 +101,9 @@ export enum MittEnum {
/** 视频下载状态更新 */
VIDEO_DOWNLOAD_STATUS_UPDATED = 'videoDownloadStatusUpdated',
/** 切换语言页面 */
VOICE_RECORD_TOGGLE = 'voiceRecordToggle'
VOICE_RECORD_TOGGLE = 'voiceRecordToggle',
/** 消息多选 */
MSG_MULTI_CHOOSE = 'msgMultiChoose'
}
/** 主题类型 */
@@ -632,6 +634,8 @@ export enum ImUrlEnum {
APPLY_UN_READ_COUNT = 'applyUnReadCount',
/** 请求申请页面 */
REQUEST_APPLY_PAGE = 'requestApplyPage',
/** 请求通知页面 */
REQUEST_NOTICE_PAGE = 'requestNoticePage',
/** 获取联系人列表 */
GET_CONTACT_LIST = 'getContactList',
/** 搜索好友 */
@@ -686,7 +690,10 @@ export enum ImUrlEnum {
MARK_MSG_READ = 'markMsgRead',
/** 移出群成员 */
REMOVE_GROUP_MEMBER = 'removeGroupMember',
CHECK_EMAIL = 'checkEmail'
CHECK_EMAIL = 'checkEmail',
MERGE_MSG = 'mergeMsg',
GET_USER_BY_IDS = 'getUserByIds'
}
// 滚动意图管理枚举
@@ -696,3 +703,8 @@ export enum ScrollIntentEnum {
NEW_MESSAGE = 'new_message', // 新消息到达
LOAD_MORE = 'load_more' // 加载更多历史消息
}
export enum MergeMessageType {
SINGLE = 1,
MERGE = 2
}
+7
View File
@@ -201,6 +201,13 @@ export const useChatMain = (isHistoryMode = false) => {
handleCopy(item.message.body.content, true)
}
},
{
label: '多选',
icon: 'list-checkbox',
click: () => {
chatStore.setMsgMultiChoose(true)
}
},
{
label: '翻译',
icon: 'translate',
+19 -2
View File
@@ -41,6 +41,22 @@ export const useMessage = () => {
}
}
/**
* 预加载聊天室
* @param roomId
*/
const preloadChatRoom = async (roomId: string = '1') => {
globalStore.updateCurrentSessionRoomId(roomId)
try {
await chatStore.changeRoom()
await markMsgRead(roomId)
chatStore.markSessionRead(roomId || '1')
await globalStore.updateGlobalUnreadCount()
} catch (error) {
console.error('尝试进入聊天室时出现错误:', error)
}
}
/**
* 删除会话
* @param roomId 会话信息
@@ -223,9 +239,10 @@ export const useMessage = () => {
return 'logout'
},
click: async (item: SessionItem) => {
console.log('删除好友或退出群聊执行')
// 单聊:删除好友
if (item.type === RoomTypeEnum.SINGLE) {
await contactStore.onDeleteContact(item.id)
await contactStore.onDeleteContact(item.detailId)
await handleMsgDelete(item.roomId)
window.$message.success('已删除好友')
return
@@ -295,5 +312,5 @@ export const useMessage = () => {
useMitt.off(MittEnum.SHRINK_WINDOW, () => {})
})
return { msgBoxShow, handleMsgClick, handleMsgDelete, handleMsgDblclick, menuList, specialMenuList }
return { msgBoxShow, handleMsgClick, handleMsgDelete, handleMsgDblclick, menuList, specialMenuList, preloadChatRoom }
}
+1 -1
View File
@@ -49,7 +49,7 @@ export const useMockMessage = () => {
},
status: MessageStatusEnum.PENDING
},
sendTime: String(currentTimeStamp),
sendTime: currentTimeStamp,
loading: true
}
}
+31 -35
View File
@@ -361,7 +361,7 @@ export const useMsgInput = (messageInputDom: Ref) => {
}
const msg = await messageStrategy.getMsg(msgInput.value, reply.value)
const atUidList = extractAtUserIds(msgInput.value, groupStore.userList)
const tempMsgId = Date.now().toString()
const tempMsgId = 'T' + Date.now().toString()
// 根据消息类型创建消息体
const messageBody = {
@@ -379,12 +379,10 @@ export const useMsgInput = (messageInputDom: Ref) => {
console.log('👾临时消息:', tempMsg)
// 设置发送状态的定时器
const statusTimer = setTimeout(() => {
chatStore.updateMsg({
msgId: tempMsgId,
status: MessageStatusEnum.SENDING
})
}, 800)
chatStore.updateMsg({
msgId: tempMsgId,
status: MessageStatusEnum.SENDING
})
try {
// 如果是图片或表情消息,需要先上传文件
@@ -472,8 +470,10 @@ export const useMsgInput = (messageInputDom: Ref) => {
msgId: message.oldMsgId,
status: MessageStatusEnum.SUCCESS,
newMsgId: message.message.id,
body: message.message.body
body: message.message.body,
timeBlock: message.timeBlock
})
useMitt.emit(MittEnum.CHAT_SCROLL_BOTTOM)
}
// 监听错误响应
@@ -483,6 +483,7 @@ export const useMsgInput = (messageInputDom: Ref) => {
msgId: msgId,
status: MessageStatusEnum.FAILED
})
useMitt.emit(MittEnum.CHAT_SCROLL_BOTTOM)
}
await invoke(TauriCommand.SEND_MSG, {
@@ -496,16 +497,6 @@ export const useMsgInput = (messageInputDom: Ref) => {
errorChannel
})
// 停止发送状态的定时器
clearTimeout(statusTimer)
// 更新消息状态为成功,并使用服务器返回的消息体
// chatStore.updateMsg({
// msgId: tempMsgId,
// status: MessageStatusEnum.SUCCESS,
// newMsgId: res,
// })
// 更新会话最后活动时间
chatStore.updateSessionLastActiveTime(globalStore.currentSession!.roomId)
@@ -520,7 +511,6 @@ export const useMsgInput = (messageInputDom: Ref) => {
}
} catch (error) {
console.error('消息发送失败:', error)
clearTimeout(statusTimer)
chatStore.updateMsg({
msgId: tempMsgId,
status: MessageStatusEnum.FAILED
@@ -963,8 +953,10 @@ export const useMsgInput = (messageInputDom: Ref) => {
msgId: message.oldMsgId,
status: MessageStatusEnum.SUCCESS,
newMsgId: message.message.id,
body: message.message.body
body: message.message.body,
timeBlock: message.timeBlock
})
useMitt.emit(MittEnum.CHAT_SCROLL_BOTTOM)
}
// 监听错误响应
@@ -974,6 +966,7 @@ export const useMsgInput = (messageInputDom: Ref) => {
msgId: msgId,
status: MessageStatusEnum.FAILED
})
useMitt.emit(MittEnum.CHAT_SCROLL_BOTTOM)
}
await invoke(TauriCommand.SEND_MSG, {
@@ -1047,8 +1040,10 @@ export const useMsgInput = (messageInputDom: Ref) => {
msgId: message.oldMsgId,
status: MessageStatusEnum.SUCCESS,
newMsgId: message.message.id,
body: message.message.body
body: message.message.body,
timeBlock: message.timeBlock
})
useMitt.emit(MittEnum.CHAT_SCROLL_BOTTOM)
}
// 监听错误响应
@@ -1058,6 +1053,7 @@ export const useMsgInput = (messageInputDom: Ref) => {
msgId: msgId,
status: MessageStatusEnum.FAILED
})
useMitt.emit(MittEnum.CHAT_SCROLL_BOTTOM)
}
await invoke(TauriCommand.SEND_MSG, {
@@ -1158,8 +1154,10 @@ export const useMsgInput = (messageInputDom: Ref) => {
msgId: message.oldMsgId,
status: MessageStatusEnum.SUCCESS,
newMsgId: message.message.id,
body: message.message.body
body: message.message.body,
timeBlock: message.timeBlock
})
useMitt.emit(MittEnum.CHAT_SCROLL_BOTTOM)
}
// 监听错误响应
@@ -1169,6 +1167,7 @@ export const useMsgInput = (messageInputDom: Ref) => {
msgId: msgId,
status: MessageStatusEnum.FAILED
})
useMitt.emit(MittEnum.CHAT_SCROLL_BOTTOM)
}
await invoke(TauriCommand.SEND_MSG, {
@@ -1232,7 +1231,7 @@ export const useMsgInput = (messageInputDom: Ref) => {
duration: voiceData.duration,
filename: voiceData.filename
}
const tempMsgId = Date.now().toString()
const tempMsgId = 'T' + Date.now().toString()
// 创建消息体(初始使用本地路径)
const messageBody = {
@@ -1260,7 +1259,7 @@ export const useMsgInput = (messageInputDom: Ref) => {
body: messageBody,
messageMarks: {}
},
sendTime: new Date().toISOString(),
sendTime: Date.now(),
loading: false
}
@@ -1268,12 +1267,10 @@ export const useMsgInput = (messageInputDom: Ref) => {
chatStore.pushMsg(tempMsg)
// 设置发送状态的定时器
const statusTimer = setTimeout(() => {
chatStore.updateMsg({
msgId: tempMsgId,
status: MessageStatusEnum.SENDING
})
}, 800)
chatStore.updateMsg({
msgId: tempMsgId,
status: MessageStatusEnum.SENDING
})
try {
// 获取语音消息策略
@@ -1318,8 +1315,10 @@ export const useMsgInput = (messageInputDom: Ref) => {
msgId: message.oldMsgId,
status: MessageStatusEnum.SUCCESS,
newMsgId: message.message.id,
body: message.message.body
body: message.message.body,
timeBlock: message.timeBlock
})
useMitt.emit(MittEnum.CHAT_SCROLL_BOTTOM)
}
// 监听错误响应
@@ -1329,6 +1328,7 @@ export const useMsgInput = (messageInputDom: Ref) => {
msgId: msgId,
status: MessageStatusEnum.FAILED
})
useMitt.emit(MittEnum.CHAT_SCROLL_BOTTOM)
}
await invoke(TauriCommand.SEND_MSG, {
@@ -1336,8 +1336,6 @@ export const useMsgInput = (messageInputDom: Ref) => {
successChannel: voiceSuccessChannel,
errorChannel: voiceErrorChannel
})
// 停止发送状态的定时器
clearTimeout(statusTimer)
// 更新会话最后活动时间
chatStore.updateSessionLastActiveTime(globalStore.currentSession!.roomId)
@@ -1347,7 +1345,6 @@ export const useMsgInput = (messageInputDom: Ref) => {
// asset:// 协议不需要手动释放
}
} catch (apiError: any) {
clearTimeout(statusTimer)
chatStore.updateMsg({
msgId: tempMsgId,
status: MessageStatusEnum.FAILED
@@ -1355,7 +1352,6 @@ export const useMsgInput = (messageInputDom: Ref) => {
throw new Error(`发送消息失败: ${apiError.message || apiError}`)
}
} catch (uploadError) {
clearTimeout(statusTimer)
chatStore.updateMsg({
msgId: tempMsgId,
status: MessageStatusEnum.FAILED
+28 -33
View File
@@ -1,10 +1,5 @@
import { useEventListener, useNetwork, useTimeoutFn } from '@vueuse/core'
import { RoomTypeEnum } from '@/enums'
import webSocket from '@/services/webSocketAdapter'
import { useChatStore } from '@/stores/chat'
import { useContactStore } from '@/stores/contacts'
import { useGlobalStore } from '@/stores/global'
import { useGroupStore } from '@/stores/group'
import { isMobile } from '@/utils/PlatformConstants'
/**
@@ -52,7 +47,7 @@ export const useNetworkReconnect = () => {
}
// 执行数据刷新
refreshAllData()
// refreshAllData()
}
// 网络变为离线时记录时间
@@ -89,7 +84,7 @@ export const useNetworkReconnect = () => {
// 如果离线超过30秒,则刷新数据
if (lastOfflineTimestamp > 0 && currentTime - lastOfflineTimestamp > 30000) {
console.log('[Network] 移动端应用从后台恢复,刷新数据')
refreshAllData()
// refreshAllData()
lastOfflineTimestamp = 0
}
}
@@ -143,45 +138,45 @@ export const useNetworkReconnect = () => {
webSocket.forceReconnect()
// 刷新数据
refreshAllData()
// refreshAllData()
}
}
/**
* 刷新所有重要数据
*/
const refreshAllData = async () => {
const chatStore = useChatStore()
const globalStore = useGlobalStore()
const groupStore = useGroupStore()
const contactStore = useContactStore()
// const refreshAllData = async () => {
// const chatStore = useChatStore()
// const globalStore = useGlobalStore()
// const groupStore = useGroupStore()
// const contactStore = useContactStore()
// 刷新会话列表
await chatStore.getSessionList(true)
// 如果当前有选中的聊天室,重置并刷新消息列表
if (globalStore.currentSession?.roomId) {
// 获取最新消息
await chatStore.resetAndRefreshCurrentRoomMessages()
}
// 如果当前是群聊,刷新群组信息
if (globalStore.currentSession?.type === RoomTypeEnum.GROUP) {
await groupStore.getGroupUserList(globalStore.currentSession.roomId)
}
// 刷新联系人列表
await contactStore.getContactList(true)
// 更新未读消息计数
await globalStore.updateGlobalUnreadCount()
// 刷新在线用户列表
await groupStore.refreshGroupMembers()
// // 刷新会话列表
// await chatStore.getSessionList(true)
// // 如果当前有选中的聊天室,重置并刷新消息列表
// // if (globalStore.currentSession?.roomId) {
// // // 获取最新消息
// // await chatStore.resetAndRefreshCurrentRoomMessages()
// // }
// // 如果当前是群聊,刷新群组信息
// if (globalStore.currentSession?.type === RoomTypeEnum.GROUP) {
// await groupStore.getGroupUserList(globalStore.currentSession.roomId)
// }
// // 刷新联系人列表
// await contactStore.getContactList(true)
// // 更新未读消息计数
// await globalStore.updateGlobalUnreadCount()
// // 刷新在线用户列表
// await groupStore.refreshGroupMembers()
console.log('[Network] 数据刷新完成')
}
// console.log('[Network] 数据刷新完成')
// }
return {
isOnline,
offlineAt,
onlineAt,
refreshAllData,
// refreshAllData,
checkConnectionHealth
}
}
+39 -5
View File
@@ -6,9 +6,41 @@ import { info } from '@tauri-apps/plugin-log'
import { EventEnum } from '@/enums'
import { isCompatibility, isWindows } from '@/utils/PlatformConstants'
type WebviewWindowOpt = {
title: string
label: string
width: number
height: number
wantCloseWindow?: string
resizable: boolean
minW: number
minH: number
transparent?: boolean
visible: boolean
queryParams?: Record<string, string | number | boolean>
}
/** 判断是兼容的系统 */
const isCompatibilityMode = computed(() => isCompatibility())
export const useWindow = () => {
const createWindow = async (opt: WebviewWindowOpt) => {
const { title, label, width, height, wantCloseWindow, resizable, minW, minH, transparent, visible, queryParams } =
opt
await createWebviewWindow(
title,
label,
width,
height,
wantCloseWindow,
resizable,
minW,
minH,
transparent,
visible,
queryParams
)
}
/**
* 创建窗口
* @param title 窗口标题
@@ -36,16 +68,17 @@ export const useWindow = () => {
visible = false,
queryParams?: Record<string, string | number | boolean>
) => {
const checkLabel = computed(() => {
const checkLabel = () => {
/** 如果是打开独立窗口就截取label中的固定label名称 */
if (label.includes(EventEnum.ALONE)) {
return label.replace(/\d/g, '')
} else {
return label
}
})
}
label = checkLabel()
// 构建URL,包含查询参数
let url = `/${checkLabel.value}`
let url = `/${label.split('--')[0]}`
if (queryParams && Object.keys(queryParams).length > 0) {
const searchParams = new URLSearchParams()
Object.entries(queryParams).forEach(([key, value]) => {
@@ -120,7 +153,7 @@ export const useWindow = () => {
* const payload = await getWindowPayload<MyPayload>('my-window')
*/
const getWindowPayload = async <T>(windowLabel: string) => {
return invoke<T>('get_window_payload', { label: windowLabel })
return await invoke<T>('get_window_payload', { label: windowLabel })
}
/**
@@ -272,6 +305,7 @@ export const useWindow = () => {
checkWinExist,
setResizable,
sendWindowPayload,
getWindowPayload
getWindowPayload,
createWindow
}
}
+14 -2
View File
@@ -132,13 +132,16 @@ import { MittEnum } from '@/enums'
import { useMitt } from '@/hooks/useMitt.ts'
import { useWindow } from '@/hooks/useWindow'
import router from '@/router'
import { useChatStore } from '@/stores/chat.ts'
import { useGlobalStore } from '@/stores/global.ts'
import { useSettingStore } from '@/stores/setting.ts'
import * as ImRequestUtils from '@/utils/ImRequestUtils'
import { isMac, isWindows } from '@/utils/PlatformConstants'
import { createGroup, options, renderLabel, renderSourceList, renderTargetList } from './model.tsx'
import { options, renderLabel, renderSourceList, renderTargetList } from './model.tsx'
const { createWebviewWindow } = useWindow()
const chatStore = useChatStore()
const settingStore = useSettingStore()
const globalStore = useGlobalStore()
const { page } = storeToRefs(settingStore)
@@ -235,7 +238,16 @@ const resetCreateGroupState = () => {
const handleCreateGroup = async () => {
if (selectedValue.value.length < 2) return
try {
await createGroup(selectedValue.value)
const result = await ImRequestUtils.createGroup({ uidList: selectedValue.value })
// 创建成功后刷新会话列表以显示新群聊
await chatStore.getSessionList(true)
// 如果API返回了id,切换到新创建的群聊
if (result?.id) {
globalStore.updateCurrentSessionRoomId(result.id)
}
resetCreateGroupState()
window.$message.success('创建群聊成功')
} catch (error) {
-12
View File
@@ -4,7 +4,6 @@ import { useContactStore } from '@/stores/contacts.ts'
import { useGlobalStore } from '@/stores/global.ts'
import { useGroupStore } from '@/stores/group.ts'
import { AvatarUtils } from '@/utils/AvatarUtils'
import * as ImRequestUtils from '@/utils/ImRequestUtils'
const contactStore = useContactStore()
const groupStore = useGroupStore()
@@ -57,17 +56,6 @@ export const getFilteredOptions = () => {
})
}
export const createGroup = async (selectedUids: string[]) => {
try {
const uidList = selectedUids.map((uid) => uid)
const result = await ImRequestUtils.createGroup({ uidList })
return result
} catch (error) {
console.error('创建群聊失败:', error)
throw error
}
}
// 统一的源列表渲染函数,通过参数控制是否使用过滤后的选项
export const renderSourceList = (preSelectedFriendId = '', enablePreSelection = true): TransferRenderSourceList => {
return ({ onCheck, checkedOptions, pattern }) => {
+13 -2
View File
@@ -30,7 +30,7 @@ import { emitTo, listen } from '@tauri-apps/api/event'
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
import { info } from '@tauri-apps/plugin-log'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import { ChangeTypeEnum, MittEnum, ModalEnum, NotificationTypeEnum, OnlineEnum, TauriCommand } from '@/enums'
import { ChangeTypeEnum, MittEnum, ModalEnum, MsgEnum, NotificationTypeEnum, OnlineEnum, TauriCommand } from '@/enums'
import { useCheckUpdate } from '@/hooks/useCheckUpdate'
import { useMitt } from '@/hooks/useMitt.ts'
import type { MarkItemType, MessageType, RevokedMsgType, UserItem } from '@/services/types.ts'
@@ -46,6 +46,7 @@ import { useConfigStore } from '@/stores/config'
import { useContactStore } from '@/stores/contacts.ts'
import { useGlobalStore } from '@/stores/global.ts'
import { useGroupStore } from '@/stores/group'
import { useSettingStore } from '@/stores/setting'
import { useUserStore } from '@/stores/user'
import { audioManager } from '@/utils/AudioManager'
import { isWindows } from '@/utils/PlatformConstants'
@@ -72,7 +73,7 @@ const AsyncLeft = defineAsyncComponent({
const AsyncCenter = defineAsyncComponent({
loader: async () => {
await import('./left/index.vue')
loadingText.value = '正在加载中间面板...'
loadingText.value = '正在加载数据中...'
const comp = await import('./center/index.vue')
// 加载所有会话
@@ -118,12 +119,18 @@ const groupStore = useGroupStore()
const userStore = useUserStore()
const chatStore = useChatStore()
const configStore = useConfigStore()
const settingStore = useSettingStore()
const { checkUpdate, CHECK_UPDATE_TIME } = useCheckUpdate()
const userUid = computed(() => userStore.userInfo!.uid)
const shrinkStatus = ref(false)
// 播放消息音效
const playMessageSound = async () => {
// 检查是否开启了消息提示音
if (!settingStore.notification?.messageSound) {
return
}
try {
const audio = new Audio('/sound/message.mp3')
await audioManager.play(audio, 'message-notification')
@@ -305,7 +312,11 @@ useMitt.on(WsResponseMessageType.MY_ROOM_INFO_CHANGE, (data: { myName: string; r
})
useMitt.on(WsResponseMessageType.RECEIVE_MESSAGE, async (data: MessageType) => {
if (userStore.isMe(data.fromUser.uid) && data.message.type !== MsgEnum.MERGE) {
return
}
chatStore.pushMsg(data)
data.message.sendTime = new Date(data.message.sendTime).getTime()
await invokeSilently(TauriCommand.SAVE_MSG, {
data
+113 -13
View File
@@ -26,19 +26,47 @@
<div class="flex justify-between px-25px flex-1 items-center py-10px">
<div v-for="item in options" :key="item.icon" class="flex flex-wrap items-center" @click="clickItem(item.icon)">
<svg class="h-24px w-24px iconpark-icon"><use :href="`#${item.icon}`"></use></svg>
<svg
v-if="item.showArrow"
:class="['h-15px w-15px iconpark-icon transition-transform duration-300', item.isRotate ? 'rotate' : '']">
<use href="#down" />
</svg>
<div v-if="item.label !== 'file' && item.label !== 'image'">
<svg class="h-24px w-24px iconpark-icon"><use :href="`#${item.icon}`"></use></svg>
<svg
v-if="item.showArrow"
:class="['h-15px w-15px iconpark-icon transition-transform duration-300', item.isRotate ? 'rotate' : '']">
<use href="#down" />
</svg>
</div>
<div v-else-if="item.label === 'file'">
<van-uploader multiple :after-read="afterReadFile">
<svg class="h-24px w-24px iconpark-icon"><use :href="`#${item.icon}`"></use></svg>
<svg
v-if="item.showArrow"
:class="[
'h-15px w-15px iconpark-icon transition-transform duration-300',
item.isRotate ? 'rotate' : ''
]">
<use href="#down" />
</svg>
</van-uploader>
</div>
<div v-else-if="item.label === 'image'">
<van-uploader accept="image/*" multiple :after-read="afterReadImage">
<svg class="h-24px w-24px iconpark-icon"><use :href="`#${item.icon}`"></use></svg>
<svg
v-if="item.showArrow"
:class="[
'h-15px w-15px iconpark-icon transition-transform duration-300',
item.isRotate ? 'rotate' : ''
]">
<use href="#down" />
</svg>
</van-uploader>
</div>
</div>
</div>
<!-- 展开面板 -->
<Transition @before-enter="beforeEnter" @enter="enter" @leave="leave">
<div v-show="isPanelVisible" class="w-full overflow-hidden bg-#13987f flex flex-col">
<div style="height: 180px"><!-- 模拟内容高度 --></div>
<div style="height: 180px"></div>
</div>
</Transition>
</div>
@@ -46,7 +74,79 @@
</template>
<script setup lang="ts">
import type { UploaderFileListItem } from 'vant/es'
import { useMobileStore } from '@/stores/mobile'
import 'vant/es/dialog/style'
const uploadFileList = ref<
{
url: string
status: 'uploading' | 'failed' | 'done'
message: string
}[]
>([])
const afterReadImage = (fileList: UploaderFileListItem | UploaderFileListItem[]) => {
const validTypes = ['image/jpeg', 'image/png', 'image/gif']
const files = Array.isArray(fileList) ? fileList : [fileList]
console.log('选择的文件:', files)
for (const file of files) {
const rawFile = file.file
if (!rawFile) {
console.log('文件不存在:', file)
continue
}
if (!validTypes.includes(rawFile.type)) {
console.log('已过滤非图片文件:', file)
if (!Array.isArray(fileList)) {
window.$message.warning('只能选择图片哦~')
}
continue
}
uploadFileList.value.push({
url: file.url as string,
status: 'done',
message: '待上传'
})
console.log('已添加文件:', file)
}
}
const afterReadFile = (fileList: UploaderFileListItem | UploaderFileListItem[]) => {
const imageTypes = ['image/jpeg', 'image/png', 'image/gif']
const files = Array.isArray(fileList) ? fileList : [fileList]
console.log('选择的文件:', files)
for (const file of files) {
const rawFile = file.file
if (!rawFile) {
console.log('文件不存在:', file)
continue
}
// ✅ 只保留非图片文件
if (imageTypes.includes(rawFile.type)) {
console.log('已过滤图片文件:', file)
continue
}
uploadFileList.value.push({
url: file.url as string,
status: 'done',
message: '待上传(非图片)'
})
console.log('已选择文件:', file)
}
}
// ==== 类型声明(让 send 有类型提示)====
interface MsgInputReturn {
@@ -109,12 +209,12 @@ const handleSend = async () => {
// ==== 展开面板 ====
const options = ref([
{ icon: 'smiling-face', showArrow: false, isRotate: false },
{ icon: 'screenshot', showArrow: true, isRotate: false },
{ icon: 'file2', showArrow: true, isRotate: false },
{ icon: 'photo', showArrow: false, isRotate: false },
{ icon: 'voice', showArrow: false, isRotate: false },
{ icon: 'history', showArrow: true, isRotate: false }
{ label: 'emoji', icon: 'smiling-face', showArrow: true, isRotate: false },
{ label: 'cut', icon: 'screenshot', showArrow: true, isRotate: false },
{ label: 'file', icon: 'file', showArrow: false, isRotate: true },
{ label: 'image', icon: 'photo', showArrow: false, isRotate: true },
{ label: 'video', icon: 'voice', showArrow: true, isRotate: false },
{ label: 'history', icon: 'history', showArrow: true, isRotate: false }
])
const isPanelVisible = ref(false)
@@ -13,9 +13,7 @@
<div class="w-full h-full overflow-hidden flex items-center justify-center">
<div :class="props.isOfficial ? ['chat-room-name-official'] : ['chat-room-name']">
<div class="truncate whitespace-nowrap overflow-hidden text-ellipsis w-full text-center">
{{
gloabalStore.currentSession.remark ? gloabalStore.currentSession.remark : gloabalStore.currentSession.name
}}
{{ props.roomName }}
</div>
<svg v-if="props.isOfficial" class="w-18px h-18px iconpark-icon text-#1A9B83"><use href="#auth"></use></svg>
</div>
@@ -31,9 +29,7 @@
<script setup lang="ts">
import router from '@/router'
import { useGlobalStore } from '@/stores/global'
const gloabalStore = useGlobalStore()
const props = defineProps({
msgCount: {
type: Number,
@@ -54,6 +50,10 @@ const props = defineProps({
enableShadow: {
type: Boolean,
default: true
},
roomName: {
type: String,
default: false
}
})
@@ -0,0 +1,262 @@
<template>
<n-flex vertical class="select-none">
<n-flex
v-if="props.closeHeader === true ? false : true"
align="center"
justify="space-between"
class="color-[--text-color] px-20px py-10px">
<p class="text-16px">{{ props.type === 'friend' ? '好友通知' : '群通知' }}</p>
<svg class="size-18px cursor-pointer">
<use href="#delete"></use>
</svg>
</n-flex>
<n-virtual-list
:style="{
maxHeight: props.customHeight
? props.customHeight + 'px'
: 'max-height: calc(100vh / var(--page-scale, 1) - 80px)'
}"
:items="applyList"
:item-size="87"
:item-resizable="true"
@scroll="handleScroll"
ref="virtualListRef">
<template #default="{ item }">
<div class="flex gap-2 w-full text-14px mb-15px">
<div class="flex h-full">
<n-avatar
round
size="large"
:src="
avatarSrc(
groupStore.getUserInfo(isCurrentUser(item.senderId) ? item.operateId : item.senderId)?.avatar!
)
" />
</div>
<div class="flex-1 flex flex-col gap-10px">
<div
@click="isCurrentUser(item.senderId) ? (currentUserId = item.operateId) : (currentUserId = item.senderId)"
class="flex justify-between text-14px text-#2DA38D">
{{ groupStore.getUserInfo(isCurrentUser(item.senderId) ? item.operateId : item.senderId)?.name }}
</div>
<div class="flex justify-between text-gray-500 text-12px">
<span>
{{ applyMsg(item) }}
</span>
<span>
{{ formatTimestamp(item.createTime) }}
</span>
</div>
<div v-show="isFriendApplyOrGroupInvite(item)" class="flex gap-2 flex-1 text-12px text-gray-500">
<div class="whitespace-nowrap">留言:</div>
<n-ellipsis :tooltip="true" expand-trigger="click" line-clamp="2" style="max-width: 100%">
{{ item.content }}
</n-ellipsis>
</div>
</div>
<div class="flex w-17 max-h-45px flex-col items-center justify-center">
<n-flex
align="center"
:size="10"
v-if="item.status === RequestNoticeAgreeStatus.UNTREATED && !isCurrentUser(item.senderId)">
<n-button secondary :loading="loadingMap[item.applyId]" @click="handleAgree(item.applyId)">接受</n-button>
<n-dropdown
trigger="click"
:options="dropdownOptions"
@select="(key: string) => handleFriendAction(key, item.applyId)">
<n-icon class="cursor-pointer px-6px">
<svg class="size-16px color-[--text-color]">
<use href="#more"></use>
</svg>
</n-icon>
</n-dropdown>
</n-flex>
<n-dropdown
trigger="click"
:options="dropdownOptions"
@select="(key: string) => handleFriendAction(key, item.applyId)"
v-if="item.status === RequestNoticeAgreeStatus.UNTREATED && !isCurrentUser(item.senderId)">
<n-icon class="cursor-pointer px-6px rounded-10px bg-gray-300 h-50% items-center flex">
<svg class="size-16px color-[--text-color]">
<use href="#more"></use>
</svg>
</n-icon>
</n-dropdown>
<span class="text-(12px #64a29c)" v-else-if="item.status === RequestNoticeAgreeStatus.ACCEPTED">
已同意
</span>
<span class="text-(12px #c14053)" v-else-if="item.status === RequestNoticeAgreeStatus.REJECTED">
已拒绝
</span>
<span class="text-(12px #909090)" v-else-if="item.status === RequestNoticeAgreeStatus.IGNORE">已忽略</span>
<span
class="text-(12px #64a29c)"
:class="{ 'text-(12px #c14053)': item.status === RequestNoticeAgreeStatus.REJECTED }"
v-else-if="isCurrentUser(item.senderId)">
{{
isAccepted(item)
? '已同意'
: item.status === RequestNoticeAgreeStatus.REJECTED
? '对方已拒绝'
: '等待验证'
}}
</span>
</div>
</div>
</template>
</n-virtual-list>
<!-- 空数据提示 -->
<n-flex v-if="applyList.length === 0" vertical justify="center" align="center" class="py-40px">
<n-empty description="暂无好友申请" />
</n-flex>
</n-flex>
</template>
<script setup lang="ts">
import { NoticeType, RequestNoticeAgreeStatus } from '@/services/types.ts'
import { useContactStore } from '@/stores/contacts.ts'
import { useUserStore } from '@/stores/user'
import { AvatarUtils } from '@/utils/AvatarUtils'
import { formatTimestamp } from '@/utils/ComputedTime.ts'
import { useGroupStore } from '~/src/stores/group'
const userStore = useUserStore()
const contactStore = useContactStore()
const groupStore = useGroupStore()
const currentUserId = ref('0')
const loadingMap = ref<Record<string, boolean>>({})
const virtualListRef = ref()
const isLoadingMore = ref(false)
const props = defineProps<{
type: 'friend' | 'group'
customHeight?: number
closeHeader?: boolean
}>()
// 检查好友申请是否已被接受
const isAccepted = (item: any) => {
// 使用缓存集合快速检查目标用户是否在联系人列表中
return item.status !== RequestNoticeAgreeStatus.UNTREATED
}
const applyList = computed(() => {
return contactStore.requestFriendsList.filter((item) => {
if (props.type === 'friend') {
return item.type === 2
} else {
return item.type === 1
}
})
})
// 判断是否为好友申请或者群申请、群邀请
const isFriendApplyOrGroupInvite = (item: any) => {
return (
item.eventType === NoticeType.FRIEND_APPLY ||
item.eventType === NoticeType.GROUP_INVITE ||
item.eventType === NoticeType.GROUP_INVITE_ME ||
item.eventType === NoticeType.ADD_ME
)
}
const applyMsg = computed(() => (item: any) => {
if (props.type === 'friend') {
return isCurrentUser(item.senderId) ? (isAccepted(item) ? '已同意你的请求' : '正在验证你的邀请') : '请求加为好友'
} else {
if (isFriendApplyOrGroupInvite(item)) {
return isCurrentUser(item.senderId) ? '已同意加入' + item.content : '邀请你加入' + item.content
} else if (item.eventType === NoticeType.GROUP_MEMBER_DELETE) {
return '已被' + groupStore.getUserInfo(item.senderId)!.name + '踢出' + item.content
}
}
})
// 下拉菜单选项
const dropdownOptions = [
{
label: '拒绝',
key: 'reject'
},
{
label: '忽略',
key: 'ignore'
}
]
const avatarSrc = (url: string) => AvatarUtils.getAvatarUrl(url)
// 判断是否为当前登录用户
const isCurrentUser = (uid: string) => {
return uid === userStore.userInfo!.uid
}
// 处理滚动事件
const handleScroll = (e: Event) => {
if (isLoadingMore.value) return
const { scrollTop, scrollHeight, clientHeight } = e.target as HTMLElement
// 当滚动到距离底部20px以内时触发加载更多
if (scrollHeight - scrollTop - clientHeight < 20) {
loadMoreFriendRequests()
}
}
// 加载更多好友申请
const loadMoreFriendRequests = async () => {
// 如果已经是最后一页或正在加载中,则不再加载
if (contactStore.applyPageOptions.isLast) {
return
}
isLoadingMore.value = true
try {
await contactStore.getApplyPage(false)
} finally {
isLoadingMore.value = false
}
}
const handleAgree = async (applyId: string) => {
loadingMap.value[applyId] = true
contactStore
.onHandleInvite({
applyId,
state: 2
})
.then(() => {
setTimeout(() => {
loadingMap.value[applyId] = false
}, 600)
})
}
// 处理好友请求操作(拒绝或忽略)
const handleFriendAction = async (action: string, applyId: string) => {
loadingMap.value[applyId] = true
try {
if (action === 'reject') {
await contactStore.onHandleInvite({
applyId,
state: 0
})
} else if (action === 'ignore') {
await contactStore.onHandleInvite({
applyId,
state: 3
})
}
} finally {
setTimeout(() => {
loadingMap.value[applyId] = false
}, 600)
}
}
onMounted(() => {
// 组件挂载时刷新一次列表
contactStore.getApplyPage(true)
})
</script>
<style scoped lang="scss"></style>
+161 -91
View File
@@ -7,56 +7,30 @@
<div
class="self-center h-auto transition-transform duration-300 ease-in-out origin-top"
:style="{ transform: props.isShow ? 'scale(1) translateY(0)' : 'scale(0.62) translateY(0px)' }">
<n-avatar
:size="86"
:src="AvatarUtils.getAvatarUrl(userStore.userInfo!.avatar)"
fallback-src="/logo.png"
round />
<n-avatar :size="86" :src="AvatarUtils.getAvatarUrl(userDetailInfo!.avatar)" fallback-src="/logo.png" round />
</div>
<!-- 基本信息栏 -->
<div ref="infoBox" class="pl-2 flex gap-8px flex-col transition-transform duration-300 ease-in-out">
<!-- 名字与在线状态 -->
<div class="flex flex-warp gap-4 items-center">
<span class="font-bold text-20px text-#373838">{{ userStore.userInfo!.name }}</span>
<div class="bg-#E7EFE6 flex flex-wrap ps-2 items-center rounded-full gap-1 w-50px h-24px">
<span class="w-12px h-12px rounded-15px bg-#079669"></span>
<span class="text-bold-style" style="font-size: 12px; color: #373838">在线</span>
<span class="font-bold text-20px text-#373838">{{ userDetailInfo!.name }}</span>
<div class="bg-#E7EFE6 flex flex-wrap ps-2 px-8px items-center rounded-full gap-1 h-24px">
<span class="w-12px h-12px rounded-15px flex items-center">
<img
:src="friendUserState.url ? friendUserState.url : currentState?.url"
alt=""
class="rounded-50% size-14px" />
</span>
<span class="text-bold-style" style="font-size: 12px; color: #373838">
{{ friendUserState.title ? friendUserState.title : currentState.title }}
</span>
</div>
</div>
<!-- 在线状态点 -->
<template v-if="!statusIcon">
<n-popover trigger="hover" placement="top" :show-arrow="false">
<template #trigger>
<div
@click="openContent('在线状态', 'onlineStatus', 320, 480)"
class="z-30 absolute top-72px left-72px cursor-pointer border-(6px solid [--avatar-border-color]) rounded-full size-18px"
:class="[activeStatus === OnlineEnum.ONLINE ? 'bg-#1ab292' : 'bg-#909090']"></div>
</template>
<span>{{ activeStatus === OnlineEnum.ONLINE ? '在线' : '离线' }}</span>
</n-popover>
</template>
<!-- 独立的状态图标 -->
<template v-if="statusIcon">
<n-popover trigger="hover" placement="top" :show-arrow="false">
<template #trigger>
<div class="z-30 absolute top-72px left-72px size-26px bg-[--avatar-border-color] rounded-full">
<img
:src="statusIcon"
@click="openContent('在线状态', 'onlineStatus', 320, 480)"
class="p-4px cursor-pointer rounded-full size-18px"
alt="" />
</div>
</template>
<span>{{ currentStateTitle }}</span>
</n-popover>
</template>
<!-- 账号 -->
<div class="flex flex-warp gap-2 items-center">
<span class="text-bold-style">账号:{{ userStore.userInfo!.account }}</span>
<span class="text-bold-style">账号:{{ userDetailInfo!.account }}</span>
<span @click="toMyQRCode" class="pe-15px">
<img class="w-14px h-14px" src="@/assets/mobile/my/qr-code.webp" alt="" />
</span>
@@ -68,14 +42,14 @@
style="transform: translateZ(0)"
class="relative w-118px overflow-hidden">
<img class="block w-full" src="@/assets/mobile/my/my-medal.webp" alt="" />
<div class="text-10px absolute inset-0 flex ps-2 items-center justify-start text-white font-medium">
<div class="text-10px absolute inset-0 flex ps-2 items-center justify-around text-white font-medium">
<span class="flex items-center">
<template v-if="(userStore.userInfo?.itemIds?.length ?? 0) > 0">
<div v-if="(userStore.userInfo?.itemIds?.length ?? 0) > 0">
<span class="font-bold">已点亮</span>
<span class="medal-number">{{ userStore.userInfo?.itemIds?.length }}</span>
<span class="font-bold">枚勋章</span>
</template>
<span v-else>还没获取任何勋章</span>
</div>
<span v-else>还没勋章哦~</span>
</span>
<span class="flex ms-3">
@@ -99,41 +73,49 @@
<div class="flex flex-warp gap-2 items-center">
<div class="min-w-10 flex flex-col items-center">
<div class="fans-number">920.13W</div>
<div class="mt-2 text-bold-style">粉丝</div>
<div class="fans-title">粉丝</div>
</div>
<div class="h-20px w-1px bg-gray-300"></div>
<div class="min-w-10 flex flex-col items-center">
<div class="fans-number">120</div>
<div class="mt-2 text-bold-style">关注</div>
<div class="fans-title">关注</div>
</div>
<div class="h-20px w-1px bg-gray-300"></div>
<div class="min-w-10 flex flex-col items-center">
<div class="fans-number">43.15W</div>
<div class="mt-2 text-bold-style">点赞</div>
<div class="fans-title">点赞</div>
</div>
</div>
<div class="flex-1 justify-end flex items-center gap-3">
<div
<n-button
:disabled="loading"
@click="toEditProfile"
v-if="props.isMyPage"
class="font-bold px-4 py-10px bg-#EEF4F3 text-#373838 rounded-full text-12px">
编辑资料
</div>
<div
</n-button>
<n-button
:loading="loading"
:disabled="loading"
@click="handleDelete"
v-if="!props.isMyPage && props.isMyFriend"
class="px-4 py-10px font-bold text-center bg-red text-white rounded-full text-12px">
-&nbsp;删除好友
</div>
<div
删除
</n-button>
<n-button
:disabled="loading"
v-if="!props.isMyPage && !props.isMyFriend"
class="px-4 py-10px font-bold text-center bg-#13987f text-white rounded-full text-12px">
+&nbsp;添加好友
</div>
<div
</n-button>
<n-button
@click="toChatRoom"
:disabled="loading"
v-if="!props.isMyPage"
class="px-4 py-10px text-center font-bold bg-#EEF4F3 text-#373838 rounded-full text-12px">
私聊
</div>
</n-button>
</div>
</div>
</div>
@@ -141,53 +123,133 @@
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { OnlineEnum } from '@/enums/index.ts'
import { showDialog } from 'vant'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { useUserStatusStore } from '@/stores/userStatus'
import { AvatarUtils } from '@/utils/AvatarUtils'
import 'vant/es/dialog/style'
import { OnlineEnum } from '@/enums'
import { useMessage } from '@/hooks/useMessage.ts'
import type { UserInfoType, UserItem } from '@/services/types'
import { useChatStore } from '@/stores/chat'
import { useContactStore } from '@/stores/contacts'
import { useGlobalStore } from '@/stores/global'
import { useGroupStore } from '@/stores/group'
import { getSessionDetailWithFriends } from '@/utils/ImRequestUtils'
const router = useRouter()
const userStore = useUserStore()
const userStatusStore = useUserStatusStore()
const groupStore = useGroupStore()
const route = useRoute()
const contactStore = useContactStore()
const globalStore = useGlobalStore()
const chatStore = useChatStore()
const { preloadChatRoom } = useMessage()
const uid = route.params.uid as string
const toChatRoom = async () => {
try {
const res = await getSessionDetailWithFriends({ id: uid, roomType: 2 })
globalStore.updateCurrentSessionRoomId(res.roomId)
// 先检查会话是否已存在
const existingSession = chatStore.getSession(res.roomId)
if (!existingSession) {
// 只有当会话不存在时才更新会话列表顺序
chatStore.updateSessionLastActiveTime(res.roomId)
// 如果会话不存在,需要重新获取会话列表,但保持当前选中的会话
await chatStore.getSessionList(true)
}
await preloadChatRoom(res.roomId)
router.push(`/mobile/chatRoom/chatMain`)
} catch (error) {
console.error('私聊尝试进入聊天室失败:', error)
window.$message.error('显示会话失败')
}
}
// const toChatRoom = async () => {
// try {
// // await preloadChatRoom(uid)
// // router.push(`/mobile/chatRoom/chatMain`)
// } catch (error) {
// console.error('尝试进入私聊错误:', error)
// }
// }
// 用户详情信息,默认字段只写必要的,不加可能会报错undefined
const userDetailInfo = ref<UserItem | UserInfoType | undefined>({
activeStatus: OnlineEnum.ONLINE,
avatar: '',
lastOptTime: 0,
name: '',
uid: '',
account: ''
})
// 这个值只有在查看好友详细信息时才用
const friendUserState = ref<any>({
title: '',
url: ''
})
const { stateList } = storeToRefs(userStatusStore)
const activeStatus = ref<OnlineEnum>(OnlineEnum.ONLINE)
const getUserState = (stateId: string) => {
return stateList.value.find((state: { id: string }) => state.id === stateId)
}
onMounted(() => {
console.log('userStatusStore = ', userStatusStore)
// 如果地址中的uid存在,那当前就是好友的页面,就从地址栏获取uid拿到好友的详情信息,如果没有就默认显示自己的信息
if (uid) {
const foundedUser = groupStore.allUserInfo.find((i) => i.uid === uid)
userDetailInfo.value = foundedUser
if (foundedUser?.userStateId) {
const state = getUserState(foundedUser.userStateId)
friendUserState.value = state
}
} else {
userDetailInfo.value = userStore.userInfo
}
})
const currentState = computed(() => userStatusStore.currentState)
const animatedBox = ref<HTMLElement | null>(null)
const statusIcon = computed(() => {
const userStateId = userStore.userInfo?.userStateId
// 如果在线且有特殊状态
if (userStateId && userStateId !== '1') {
const state = stateList.value.find((s: { id: string }) => s.id === userStateId)
if (state) {
return state.url
}
}
return null
})
const loading = ref(false)
// 计算当前状态的标题
const currentStateTitle = computed(() => {
const userStateId = userStore.userInfo?.userStateId
if (userStateId && userStateId !== '1') {
const state = stateList.value.find((s: { id: string }) => s.id === userStateId)
if (state) {
return state.title
}
}
return activeStatus.value === OnlineEnum.ONLINE ? '在线' : '离线'
})
/**
* 打开内容对应窗口 [安卓版本]
* @param title 窗口的标题
* @param label 窗口的标识
* @param w 窗口的宽度
* @param h 窗口的高度
* */
const openContent = (_title: string, _label: string, _w = 840, _h = 600) => {
console.log('安卓的弹窗')
const handleDelete = () => {
showDialog({
title: '删除好友',
message: '确定删除该好友吗?',
showCancelButton: true,
confirmButtonText: '取消',
cancelButtonText: '确定'
})
.then(() => {})
.catch(async () => {
if (userDetailInfo.value?.uid) {
try {
loading.value = true
await contactStore.onDeleteContact(userDetailInfo.value.uid)
window.$message.success('已删除好友')
} catch (error) {
window.$message.warning('删除失败')
console.error('删除好友失败:', error)
}
} else {
window.$message.warning('没有找到好友哦')
}
})
.finally(() => {
loading.value = false
})
}
const toEditProfile = () => {
@@ -337,6 +399,14 @@ $font-family-sans: 'Helvetica Neue', Helvetica, Arial, sans-serif;
.fans-number {
font-size: $text-font-size-base;
font-family: $font-family-system, $font-family-windows, $font-family-chinese, $font-family-sans;
font-weight: 600;
}
.fans-title {
margin-top: 0.5rem;
font-size: 13px;
font-family: $font-family-system, $font-family-windows, $font-family-sans;
color: #757775;
}
.custom-rounded {
@@ -30,6 +30,7 @@ import type { MessageType } from '@/services/types'
import { WsResponseMessageType } from '@/services/wsType'
import { useChatStore } from '@/stores/chat'
import { useGlobalStore } from '@/stores/global'
import { useSettingStore } from '@/stores/setting'
import { useUserStore } from '@/stores/user'
import { audioManager } from '@/utils/AudioManager'
import { invokeSilently } from '@/utils/TauriInvokeHandler'
@@ -39,8 +40,14 @@ const route = useRoute()
const chatStore = useChatStore()
const userStore = useUserStore()
const globalStore = useGlobalStore()
const settingStore = useSettingStore()
const userUid = computed(() => userStore.userInfo!.uid)
const playMessageSound = async () => {
// 检查是否开启了消息提示音
if (!settingStore.notification?.messageSound) {
return
}
try {
const audio = new Audio('/sound/message.mp3')
await audioManager.play(audio, 'message-notification')
+117 -2
View File
@@ -26,28 +26,40 @@
<script setup lang="ts">
import { emitTo } from '@tauri-apps/api/event'
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
import { info } from '@tauri-apps/plugin-log'
import { type } from '@tauri-apps/plugin-os'
import { useRoute } from 'vue-router'
import SafeAreaPlaceholder from '#/components/placeholders/SafeAreaPlaceholder.vue'
import type { default as TabBarType } from '#/layout/tabBar/index.vue'
import TabBar from '#/layout/tabBar/index.vue'
import { NotificationTypeEnum, TauriCommand } from '@/enums'
import { ChangeTypeEnum, NotificationTypeEnum, TauriCommand } from '@/enums'
import { useMitt } from '@/hooks/useMitt'
import type { MessageType } from '@/services/types'
import type { MessageType, UserItem } from '@/services/types'
import { WsResponseMessageType } from '@/services/wsType'
import { useChatStore } from '@/stores/chat'
import { useGlobalStore } from '@/stores/global'
import { useGroupStore } from '@/stores/group'
import { useSettingStore } from '@/stores/setting'
import { useUserStore } from '@/stores/user'
import { audioManager } from '@/utils/AudioManager'
import { invokeSilently } from '@/utils/TauriInvokeHandler'
import { useContactStore } from '~/src/stores/contacts'
const route = useRoute()
const tabBarElement = ref<InstanceType<typeof TabBarType>>()
const chatStore = useChatStore()
const userStore = useUserStore()
const globalStore = useGlobalStore()
const groupStore = useGroupStore()
const contactStore = useContactStore()
const settingStore = useSettingStore()
const userUid = computed(() => userStore.userInfo!.uid)
const playMessageSound = async () => {
// 检查是否开启了消息提示音
if (!settingStore.notification?.messageSound) {
return
}
try {
const audio = new Audio('/sound/message.mp3')
await audioManager.play(audio, 'message-notification')
@@ -56,6 +68,109 @@ const playMessageSound = async () => {
}
}
// 处理自己加入群聊
const handleSelfAdd = async (roomId: string) => {
info('本人加入群聊,加载该群聊的会话数据')
await chatStore.addSession(roomId)
}
// 处理其他成员加入群聊
const handleOtherMemberAdd = async (user: UserItem, roomId: string) => {
info('群成员加入群聊,添加群成员数据')
groupStore.addUserItem(user, roomId)
}
// 处理群成员添加
const handleMemberAdd = async (userList: UserItem[], roomId: string) => {
for (const user of userList) {
if (isSelfUser(user.uid)) {
await handleSelfAdd(roomId)
} else {
await handleOtherMemberAdd(user, roomId)
}
}
}
useMitt.on(
WsResponseMessageType.WS_MEMBER_CHANGE,
async (param: {
roomId: string
changeType: ChangeTypeEnum
userList: UserItem[]
totalNum: number
onlineNum: number
}) => {
info('监听到群成员变更消息')
const isRemoveAction = param.changeType === ChangeTypeEnum.REMOVE || param.changeType === ChangeTypeEnum.EXIT_GROUP
if (isRemoveAction) {
await handleMemberRemove(param.userList, param.roomId)
} else {
await handleMemberAdd(param.userList, param.roomId)
}
groupStore.addGroupDetail(param.roomId)
// 更新群内的总人数
groupStore.updateGroupNumber(param.roomId, param.totalNum, param.onlineNum)
}
)
// 检查是否为当前用户
const isSelfUser = (uid: string): boolean => {
return uid === userStore.userInfo!.uid
}
// 处理自己被移除
const handleSelfRemove = async (roomId: string) => {
info('本人退出群聊,移除会话数据')
// 移除会话和群成员数据
chatStore.removeSession(roomId)
groupStore.removeAllUsers(roomId)
// 如果当前会话就是被移除的群聊,切换到其他会话
if (globalStore.currentSession?.roomId === roomId) {
globalStore.updateCurrentSessionRoomId(chatStore.sessionList[0].roomId)
}
}
// 处理其他成员被移除
const handleOtherMemberRemove = async (uid: string, roomId: string) => {
info('群成员退出群聊,移除群内的成员数据')
groupStore.removeUserItem(uid, roomId)
}
useMitt.on(WsResponseMessageType.REQUEST_APPROVAL_FRIEND, async () => {
// 刷新好友列表以获取最新状态
await contactStore.getContactList(true)
})
useMitt.on(WsResponseMessageType.ROOM_INFO_CHANGE, async (data: { roomId: string; name: string; avatar: string }) => {
// 根据roomId修改对应房间中的群名称和群头像
const { roomId, name, avatar } = data
// 更新chatStore中的会话信息
chatStore.updateSession(roomId, {
name,
avatar
})
})
// 处理群成员移除
const handleMemberRemove = async (userList: UserItem[], roomId: string) => {
for (const user of userList) {
if (isSelfUser(user.uid)) {
await handleSelfRemove(roomId)
} else {
await handleOtherMemberRemove(user.uid, roomId)
}
}
}
useMitt.on(WsResponseMessageType.USER_STATE_CHANGE, async (data: { uid: string; userStateId: string }) => {
groupStore.updateUserItem(data.uid, {
userStateId: data.userStateId
})
})
/** 测试 */
useMitt.on(WsResponseMessageType.RECEIVE_MESSAGE, async (data: MessageType) => {
console.log('[mobile/layout] 收到的消息:', data)
+251 -69
View File
@@ -15,22 +15,35 @@
<div class="flex flex-col gap-15px py-15px px-20px">
<div class="flex shadow bg-white rounded-10px w-full h-60px items-center gap-10px">
<!-- 群头像 -->
<div class="ms-15px self-center h-38px bg-gray-100 rounded-full flex items-center justify-center">
<n-badge>
<div class="flex justify-center">
<div
class="rounded-full relative bg-white w-38px h-38px overflow-hidden"
@click="openAvatarCropper(userStore.userInfo?.avatarUpdateTime)">
<n-avatar
:size="40"
:src="AvatarUtils.getAvatarUrl(globalStore.currentSession.avatar)"
class="absolute"
:size="86"
:src="AvatarUtils.getAvatarUrl(globalStore.currentSession.avatar!)"
fallback-src="/logo.png"
round />
</n-badge>
<div
class="absolute h-50% w-full bottom-0 bg-[rgb(50,50,50)] bg-clip-padding backdrop-filter backdrop-blur-sm bg-opacity-15 backdrop-saturate-100 backdrop-contrast-100"></div>
</div>
<input
ref="fileInput"
type="file"
accept="image/jpeg,image/png,image/webp"
class="hidden"
@change="handleFileChange" />
<AvatarCropper
ref="cropperRef"
v-model:show="showCropper"
:image-url="localImageUrl"
@crop="handleCrop" />
</div>
<div class="text-14px flex items-center h-full gap-5px">
<span>
{{
globalStore.currentSession.remark
? globalStore.currentSession.remark
: globalStore.currentSession.name
}}
{{ globalStore.currentSession.name }}
</span>
<span>
<svg class="w-18px h-18px iconpark-icon text-#1A9B83">
@@ -46,7 +59,7 @@
<div class="flex justify-between items-center">
<div class="text-14px">群聊成员</div>
<div class="text-12px text-#6E6E6E flex flex-wrap gap-10px items-center">
<div>{{ memberNum || 0 }}</div>
<div>{{ groupStore.countInfo?.memberNum || 0 }}</div>
<div>
<svg class="w-14px h-14px iconpark-icon">
<use href="#right"></use>
@@ -137,7 +150,7 @@
style="border-bottom: 1px solid; border-color: #ebebeb"
@click="handleCopy(globalStore.currentSession.account)"
class="flex justify-between py-15px items-center">
<div class="text-14px">群号/二维码</div>
<div class="text-14px">{{ isGroup ? '群号/二维码' : 'Hula号/二维码' }}</div>
<div class="text-12px text-#6E6E6E flex flex-wrap gap-10px items-center">
<div>{{ globalStore.currentSession.account }}</div>
<div>
@@ -152,8 +165,25 @@
<div @click="goToNotice" class="pt-15px flex flex-col text-14px gap-10px">
<div>群公告</div>
<div class="text-#707070 line-clamp-2 text-12px line-height-20px">
静夜思·李白 床前明月光疑是地上霜 举头望明月低头思故乡静夜思·李白 床前明月光疑是地上霜
举头望明月低头思故乡
{{ announList.length > 0 ? announList[0]?.content : '' }}
</div>
</div>
<div v-if="isLord || isAdmin" class="flex justify-between py-15px items-center">
<div class="text-14px">本群昵称</div>
<div class="text-12px text-#6E6E6E flex flex-wrap gap-10px items-center">
<input
style="
height: 17px;
border: none;
text-align: right;
outline: none;
font-size: 14px;
text-align: right;
"
v-model="nameValue"
@blur="handleGroupInfoUpdate"
placeholder="请输入群昵称" />
</div>
</div>
@@ -169,7 +199,9 @@
font-size: 14px;
text-align: right;
"
placeholder="请输入群昵称" />
v-model="nicknameValue"
@blur="handleInfoUpdate"
placeholder="请输入我的群昵称" />
</div>
</div>
</div>
@@ -177,34 +209,19 @@
<!-- 群备注 -->
<div class="w-full flex flex-col gap-15px rounded-10px">
<div class="ps-15px text-14px">
<span v-if="isEditingRemark">群备注</span>
<span v-if="isEditingRemark" class="text-#6E6E6E">仅自己可见</span>
<div v-if="isEditingRemark" class="flex items-center">
<n-input
ref="remarkInputRef"
v-model:value="item.remark"
size="tiny"
class="border-(1px solid #90909080)"
placeholder="请输入群聊备注"
clearable
spellCheck="false"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
@blur="handleRemarkUpdate"
@keydown.enter="handleRemarkUpdate" />
</div>
<span v-else class="cursor-pointer text-gray-200 text-14px text-left" @click="startEditRemark">
{{ item?.remark || '设置群聊备注' }} 仅自己可见
</span>
<span>群备注</span>
<span class="text-#6E6E6E">仅自己可见</span>
</div>
<!-- <div class="rounded-10px flex w-full bg-white shadow">
<div class="rounded-10px flex w-full bg-white shadow">
<div class="w-full px-15px">
<input class="h-50px w-full" style="border: none; outline: none; font-size: 14px"
placeholder="请输入群备注" />
<input
v-model="remarkValue"
class="h-50px w-full"
style="border: none; outline: none; font-size: 14px"
placeholder="请输入群备注"
@blur="handleInfoUpdate" />
</div>
</div> -->
</div>
</div>
<div class="flex bg-white rounded-10px w-full h-auto shadow">
<div class="px-15px flex flex-col w-full">
@@ -214,19 +231,23 @@
style="border-bottom: 1px solid; border-color: #ebebeb"
class="flex justify-between py-12px items-center">
<div class="text-14px">设置为置顶</div>
<n-switch v-model:value="active1" />
<n-switch :value="activeItem.top" @update:value="handleTop" />
</div>
<div
style="border-bottom: 1px solid; border-color: #ebebeb"
class="flex justify-between py-12px items-center">
<div class="text-14px">消息免打扰</div>
<n-switch v-model:value="active2" />
<n-switch :value="activeItem.muteNotification === NotificationTypeEnum.NOT_DISTURB" />
</div>
</div>
</div>
<div class="flex shadow bg-white cursor-pointer text-red text-14px rounded-10px w-full">
<div class="p-15px">删除聊天记录</div>
</div>
<!-- 解散群聊按钮 -->
<div class="mt-auto flex justify-center mb-20px">
<n-button type="error" @click="handleExit">{{ isGroup ? '解散群聊' : '退出群聊' }}</n-button>
</div>
</div>
</div>
</template>
@@ -234,30 +255,65 @@
</template>
<script setup lang="ts">
import { RoomTypeEnum } from '@/enums'
import { MittEnum, NotificationTypeEnum, RoleEnum, RoomTypeEnum } from '@/enums'
import { useAvatarUpload } from '@/hooks/useAvatarUpload'
import { useMitt } from '@/hooks/useMitt.ts'
import router from '@/router'
import type { UserItem } from '@/services/types'
import { useCachedStore } from '@/stores/cached'
import { useChatStore } from '@/stores/chat.ts'
import { useGlobalStore } from '@/stores/global'
import { useGroupStore } from '@/stores/group'
import { useUserStore } from '@/stores/user'
import { AvatarUtils } from '@/utils/AvatarUtils'
import { getGroupDetail } from '@/utils/ImRequestUtils'
import { getGroupDetail, setSessionTop, updateRoomInfo } from '@/utils/ImRequestUtils'
const dialog = useDialog()
const userStore = useUserStore()
const chatStore = useChatStore()
const globalStore = useGlobalStore()
const groupStore = useGroupStore()
const cacheStore = useCachedStore()
// TODO
const memberNum = computed(() => globalStore.currentSession.memberNum)
const isGroup = computed(() => globalStore.currentSession?.type === RoomTypeEnum.GROUP)
const isLord = computed(() => {
const currentUser = groupStore.userList.find((user) => user.uid === useUserStore().userInfo?.uid)
return currentUser?.roleId === RoleEnum.LORD
})
const isAdmin = computed(() => {
const currentUser = groupStore.userList.find((user) => user.uid === useUserStore().userInfo?.uid)
return currentUser?.roleId === RoleEnum.ADMIN
})
const announError = ref(false)
const announNum = ref(0)
const isAddAnnoun = ref(false)
const announList = ref<any[]>([])
const remarkValue = ref('')
const item = ref<any>(null)
const active1 = ref(false)
const active2 = ref(false)
const nameValue = ref('')
const avatarValue = ref('')
const nicknameValue = ref('')
const options = ref<Array<{ name: string; src: string }>>([])
// 编辑群备注相关状态
const isEditingRemark = ref(false)
const remarkInputRef = useTemplateRef('remarkInputRef')
const { currentSession: activeItem } = storeToRefs(globalStore)
const {
fileInput,
localImageUrl,
showCropper,
cropperRef,
openAvatarCropper,
handleFileChange,
handleCrop: onCrop
} = useAvatarUpload({
onSuccess: async (downloadUrl) => {
avatarValue.value = downloadUrl
}
})
const handleCrop = async (cropBlob: Blob) => {
await onCrop(cropBlob)
}
const handleCopy = (val: string) => {
if (val) {
@@ -266,27 +322,150 @@ const handleCopy = (val: string) => {
}
}
// 开始编辑群备注
const startEditRemark = () => {
isEditingRemark.value = true
nextTick(() => {
remarkInputRef.value?.focus()
const goToNotice = () => {
router.push({
path: '/mobile/chatRoom/notice',
query: {
announList: JSON.stringify(announList.value),
roomId: globalStore.currentSession.roomId
}
})
}
const goToNotice = () => {
router.push('/mobile/chatRoom/notice')
// 退出登录逻辑
async function handleExit() {
dialog.error({
title: '提示',
content: isGroup.value ? '确定要解散群聊吗?' : '确定要退出群聊吗?',
positiveText: '确定',
negativeText: '取消',
onPositiveClick: async () => {
try {
if (isGroup.value) {
if (isLord.value) {
if (activeItem.value.roomId === '1') {
window.$message.warning('无法解散频道')
return
}
groupStore.exitGroup(activeItem.value.roomId).then(() => {
window.$message.success('已解散群聊')
// 删除当前的会话
useMitt.emit(MittEnum.DELETE_SESSION, activeItem.value.roomId)
})
} else {
if (activeItem.value.roomId === '1') {
window.$message.warning('无法退出频道')
return
}
groupStore.exitGroup(activeItem.value.roomId).then(() => {
window.$message.success('已退出群聊')
// 删除当前的会话
useMitt.emit(MittEnum.DELETE_SESSION, activeItem.value.roomId)
})
}
}
router.push('/mobile/login')
} catch (error) {
console.error('创建登录窗口失败:', error)
}
},
onNegativeClick: () => {
console.log('用户点击了取消')
}
})
}
/** 判断当前用户是否拥有id为6的徽章 并且是频道 */
const hasBadge6 = computed(() => {
// 只有当 roomId 为 "1" 时才进行徽章判断(频道)
if (globalStore.currentSession?.roomId !== '1') return false
const currentUser = groupStore.getUserInfo(userStore.userInfo!.uid!)!
return currentUser?.itemIds?.includes('6')
})
/**
* 初始化群公告所需要的信息
*/
const handleInitAnnoun = async () => {
// 初始化时获取群公告
if (isGroup.value) {
const roomId = globalStore.currentSession?.roomId
if (roomId) {
await handleLoadGroupAnnoun(roomId)
}
}
}
/**
* 加载群公告
*/
const handleLoadGroupAnnoun = async (roomId: string) => {
try {
// 设置是否可以添加公告
isAddAnnoun.value = isLord.value || isAdmin.value || hasBadge6.value!
// 获取群公告列表
const data = await cacheStore.getGroupAnnouncementList(roomId, 1, 10)
if (data) {
announList.value = data.records
// 处理置顶公告
if (announList.value && announList.value.length > 0) {
const topAnnouncement = announList.value.find((item: any) => item.top)
if (topAnnouncement) {
announList.value = [topAnnouncement, ...announList.value.filter((item: any) => !item.top)]
}
}
announNum.value = parseInt(data.total, 10)
announError.value = false
} else {
announError.value = false
}
} catch (error) {
console.error('加载群公告失败:', error)
announError.value = true
}
}
/** 置顶 */
const handleTop = (value: boolean) => {
setSessionTop({ roomId: activeItem.value.roomId, top: value })
.then(() => {
// 更新本地会话状态
chatStore.updateSession(activeItem.value.roomId, { top: value })
window.$message.success(value ? '已置顶' : '已取消置顶')
})
.catch(() => {
window.$message.error('置顶失败')
})
}
// 处理群备注更新
const handleRemarkUpdate = async () => {
const handleInfoUpdate = async () => {
await cacheStore.updateMyRoomInfo({
id: item.value!.roomId,
remark: item.value!.remark,
myName: item.value!.myName || ''
id: globalStore.currentSession.roomId,
remark: remarkValue.value,
myName: nicknameValue.value
})
window.$message.success('群备注更新成功')
isEditingRemark.value = false
}
// 处理群名称更新
const handleGroupInfoUpdate = async () => {
await updateRoomInfo({
id: activeItem.value.roomId,
name: nameValue.value,
avatar: avatarValue.value
})
// 更新本地会话状态
// chatStore.updateSession(activeItem.value.roomId, {
// avatar: ''
// })
activeItem.value.avatar = avatarValue.value
window.$message.success('群信息已更新')
}
// 获取群组详情和成员信息
@@ -312,14 +491,15 @@ const fetchGroupMembers = async (roomId: string) => {
* 这里直接监听状态的值
*/
onMounted(async () => {
console.log('globalStore.currentSession --> ', globalStore.currentSession)
if (globalStore.currentSession.type === RoomTypeEnum.SINGLE) {
item.value = groupStore.getUserInfo(globalStore.currentSession.detailId)!
} else {
await getGroupDetail(globalStore.currentSession.detailId)
await handleInitAnnoun()
if (isGroup.value) {
await getGroupDetail(globalStore.currentSession.roomId)
.then((response: any) => {
item.value = response
nameValue.value = response.groupName || ''
avatarValue.value = response.avatar
nicknameValue.value = response.myName || ''
remarkValue.value = response.remark || ''
if (item.value && item.value.roomId) {
fetchGroupMembers(item.value.roomId)
}
@@ -327,8 +507,10 @@ onMounted(async () => {
.catch((e: any) => {
console.error('获取群组详情失败:', e)
})
} else {
// 这里需要拿到好友的信息
groupStore.getUserInfo(globalStore.currentSession.detailId)!
}
console.log('item --> ', item)
})
</script>
@@ -1,7 +1,12 @@
<template>
<AutoFixHeightPage>
<template #header>
<HeaderBar ref="header" :room-name="globalStore.currentSession!.name" :msg-count="1002" />
<HeaderBar
ref="header"
:room-name="
globalStore.currentSession.remark ? globalStore.currentSession.remark : globalStore.currentSession.name
"
:msg-count="1002" />
</template>
<template #container>
<ChatMain />
@@ -13,7 +13,7 @@
<img src="@/assets/mobile/chat-home/background.webp" class="w-100% relative top-0 z-1" alt="hula" />
<div :style="{ height: height + 'px' }" class="z-2 absolute flex flex-col overflow-auto min-h-70vh w-full">
<div class="flex flex-col p-20px">
<RecycleScroller :items="testData" :item-size="15" key-field="id" class="flex flex-col gap-15px">
<RecycleScroller :items="announList" :item-size="15" key-field="id" class="flex flex-col gap-15px">
<template #default="{ item }">
<!-- 公告内容块 -->
<div @click="goToNoticeDetail(item.id)" class="shadow flex p-15px bg-white rounded-10px">
@@ -22,7 +22,7 @@
<div class="flex items-center justify-between text-14px">
<span class="flex gap-5px">
<span class="text-#717171">发布人:</span>
<span class="text-black">卡仔</span>
<span class="text-black">{{ groupStore.getUserInfo(item.uid)?.name }}</span>
</span>
<span
v-if="item.isTop"
@@ -33,14 +33,12 @@
</div>
<!-- 公告内容 -->
<div class="text-14px line-clamp-3 line-height-20px text-#717171 max-h-60px">
静夜思·李白 床前明月光疑是地上霜 举头望明月低头思故乡 静夜思·李白 床前明月光疑是地上霜
举头望明月低头思故乡 静夜思·李白 床前明月光疑是地上霜 举头望明月低头思故乡 静夜思·李白
床前明月光疑是地上霜 举头望明月低头思故乡
{{ item.content }}
</div>
<div class="flex items-center justify-between text-12px">
<span class="flex gap-5px text-#717171">2025年8月13日 17:25</span>
<span class="text-#13987F">3人已读</span>
<span class="flex gap-5px text-#717171">{{ formatTimestamp(item.createTime) }}</span>
<span class="text-#13987F">128人已读</span>
</div>
</div>
</div>
@@ -53,30 +51,24 @@
</template>
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { RecycleScroller } from 'vue-virtual-scroller'
import router from '@/router'
const testData = computed(() => {
const tempList = []
for (let i = 0; i < 200; i++) {
tempList.push({
id: i,
msg: i,
isTop: i % 15 === 0
})
}
const topList = tempList.filter((item) => item.isTop)
const notTopList = tempList.filter((item) => !item.isTop)
const sortedList = topList.concat(notTopList)
return sortedList
})
import { useGroupStore } from '@/stores/group'
import { formatTimestamp } from '@/utils/ComputedTime.ts'
const route = useRoute()
const router = useRouter()
const announList = ref<any[]>([])
const groupStore = useGroupStore()
const goToNoticeDetail = (id: string) => {
// 跳转到公告编辑页面
console.log(`跳转到公告编辑页面,公告ID: ${id}`)
router.push(`/mobile/chatRoom/notice/detail/${id}`)
}
onMounted(() => {
announList.value = JSON.parse(route.query.announList as string)
})
</script>
<style scoped></style>
+5 -5
View File
@@ -161,8 +161,8 @@ import { useSettingStore } from '@/stores/setting'
import { useUserStore } from '@/stores/user'
import { AvatarUtils } from '@/utils/AvatarUtils'
import { searchFriend, searchGroup } from '@/utils/ImRequestUtils'
import router from '~/src/router'
// const { createWebviewWindow } = useWindow()
const contactStore = useContactStore()
const userStore = useUserStore()
const globalStore = useGlobalStore()
@@ -398,15 +398,15 @@ const handleButtonClick = (item: any) => {
// 处理添加好友或群聊
const handleAddFriend = async (item: any) => {
if (searchType.value === 'user' || searchType.value === 'recommend') {
// await createWebviewWindow('申请加好友', 'addFriendVerify', 380, 300, '', false, 380, 300)
globalStore.addFriendModalInfo.show = true
globalStore.addFriendModalInfo.uid = item.uid
router.push('/mobile/mobileFriends/confirmAddFriend')
} else {
// await createWebviewWindow('申请加群', 'addGroupVerify', 380, 400, '', false, 380, 400)
globalStore.addGroupModalInfo.show = true
globalStore.addGroupModalInfo.account = item.account
globalStore.addGroupModalInfo.name = item.name
globalStore.addGroupModalInfo.avatar = item.avatar
router.push('/mobile/mobileFriends/confirmAddGroup')
}
}
@@ -0,0 +1,97 @@
<template>
<div class="flex flex-1 flex-col">
<img src="@/assets/mobile/chat-home/background.webp" class="w-100% absolute top-0 z-1" alt="hula" />
<AutoFixHeightPage :show-footer="false">
<template #header>
<HeaderBar
:isOfficial="false"
:hidden-right="true"
:enable-default-background="false"
:enable-shadow="false"
room-name="添加好友" />
</template>
<template #container="{ height }">
<div
:style="{ height: height + 'px' }"
class="z-2 absolute flex flex-col gap-1 overflow-auto min-h-70vh w-full">
<!-- 内容区域 -->
<div class="w-full h-full box-border flex flex-col">
<n-flex vertical justify="center" :size="20" class="p-[55px_20px] m-20px rounded-15px bg-white">
<n-flex align="center" justify="center" :size="20">
<n-avatar round size="large" :src="avatarSrc" />
<n-flex vertical :size="10">
<p class="text-[--text-color]">{{ userInfo.name }}</p>
<p class="text-(12px [--text-color])">账号: {{ userInfo.account }}</p>
</n-flex>
</n-flex>
<n-input
v-model:value="requestMsg"
:allow-input="(value: string) => !value.startsWith(' ') && !value.endsWith(' ')"
:autosize="{
minRows: 3,
maxRows: 3
}"
:maxlength="60"
:count-graphemes="countGraphemes"
show-count
spellCheck="false"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
type="textarea"
placeholder="输入几句话对TA说些什么吧" />
<n-button class="mt-30px" color="#13987f" @click="addFriend">添加好友</n-button>
</n-flex>
</div>
</div>
</template>
</AutoFixHeightPage>
</div>
</template>
<script setup lang="ts">
import { useCommon } from '@/hooks/useCommon.ts'
import { useGlobalStore } from '@/stores/global.ts'
import { useGroupStore } from '@/stores/group'
import { useUserStore } from '@/stores/user.ts'
import { AvatarUtils } from '@/utils/AvatarUtils'
import { sendAddFriendRequest } from '@/utils/ImRequestUtils'
import router from '~/src/router'
const globalStore = useGlobalStore()
const userStore = useUserStore()
const groupStore = useGroupStore()
const { countGraphemes } = useCommon()
const userInfo = ref(groupStore.getUserInfo(globalStore.addFriendModalInfo.uid!)!)
const avatarSrc = computed(() => AvatarUtils.getAvatarUrl(userInfo.value!.avatar as string))
const requestMsg = ref()
watch(
() => globalStore.addFriendModalInfo.uid,
(newUid) => {
userInfo.value = groupStore.getUserInfo(newUid!)!
}
)
const addFriend = async () => {
await sendAddFriendRequest({
msg: requestMsg.value,
targetUid: globalStore.addFriendModalInfo.uid as string
})
window.$message.success('已发送好友申请')
setTimeout(() => {
router.push('/mobile/message')
}, 2000)
}
onMounted(async () => {
console.log(userInfo.value)
requestMsg.value = `我是${userStore.userInfo!.name}`
})
</script>
<style scoped lang="scss"></style>
@@ -0,0 +1,93 @@
<template>
<div class="flex flex-1 flex-col">
<img src="@/assets/mobile/chat-home/background.webp" class="w-100% absolute top-0 z-1" alt="hula" />
<AutoFixHeightPage :show-footer="false">
<template #header>
<HeaderBar
:isOfficial="false"
:hidden-right="true"
:enable-default-background="false"
:enable-shadow="false"
room-name="添加好友" />
</template>
<template #container="{ height }">
<div
:style="{ height: height + 'px' }"
class="z-2 absolute flex flex-col gap-1 overflow-auto min-h-70vh w-full">
<!-- 内容区域 -->
<div class="w-full h-full box-border flex flex-col">
<n-flex vertical justify="center" :size="20" class="p-[55px_20px] bg-white m-20px rounded-15px">
<n-flex align="center" justify="center" :size="20">
<n-avatar round size="large" :src="userInfo.avatar" />
<n-flex vertical :size="10">
<p class="text-[--text-color]">{{ userInfo.name }}</p>
<p class="text-(12px [--text-color])">群号: {{ userInfo.account }}</p>
</n-flex>
</n-flex>
<n-input
v-model:value="requestMsg"
:allow-input="(value: string) => !value.startsWith(' ') && !value.endsWith(' ')"
:autosize="{
minRows: 3,
maxRows: 3
}"
:maxlength="60"
:count-graphemes="countGraphemes"
show-count
spellCheck="false"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
type="textarea"
placeholder="输入验证消息" />
<n-button class="mt-120px" color="#13987f" @click="addFriend">申请加入</n-button>
</n-flex>
</div>
</div>
</template>
</AutoFixHeightPage>
</div>
</template>
<script setup lang="ts">
import { useCommon } from '@/hooks/useCommon.ts'
import router from '@/router'
import { useGlobalStore } from '@/stores/global.ts'
import { useUserStore } from '@/stores/user.ts'
import { applyGroup } from '@/utils/ImRequestUtils'
const globalStore = useGlobalStore()
const userStore = useUserStore()
const { countGraphemes } = useCommon()
const userInfo = ref(globalStore.addGroupModalInfo)
const requestMsg = ref()
watch(
() => globalStore.addGroupModalInfo,
(newUid) => {
userInfo.value = { ...newUid }
}
)
const addFriend = async () => {
await applyGroup({
msg: requestMsg.value,
account: String(globalStore.addGroupModalInfo.account)
})
window.$message.success('已发送群聊申请')
setTimeout(() => {
router.push('/mobile/message')
}, 2000)
}
onMounted(async () => {
console.log(userInfo.value)
requestMsg.value = `我是${userStore.userInfo!.name}`
})
</script>
<style scoped lang="scss"></style>
@@ -1,6 +1,11 @@
<template>
<div class="flex flex-col h-full">
<SafeAreaPlaceholder type="layout" direction="top" />
<HeaderBar
:isOfficial="false"
:hidden-right="true"
:enable-default-background="false"
:enable-shadow="false"
room-name="用户资料" />
<img src="@/assets/mobile/chat-home/background.webp" class="w-100% fixed top-0" alt="hula" />
@@ -35,7 +40,6 @@
import CommunityContent from '#/components/community/CommunityContent.vue'
import CommunityTab from '#/components/community/CommunityTab.vue'
import PersonalInfo from '#/components/my/PersonalInfo.vue'
import SafeAreaPlaceholder from '#/components/placeholders/SafeAreaPlaceholder.vue'
const isShow = ref(true)
const avatarBox = ref<HTMLElement | null>(null)
+58 -27
View File
@@ -49,34 +49,27 @@
<div class="flex flex-1 gap-2 flex-col bg-white z-1 custom-rounded">
<!-- 我的消息条 -->
<div class="grid grid-cols-[4rem_1fr_24px] h-64px px-16px border-b-[1px] border-b-solid border-b-[#e5e7eb]">
<div class="h-full flex items-center">我的消息</div>
<div class="h-full flex items-center justify-end overflow-hidden">
<div class="rounded-full h-26px w-26px bg-purple-300 border-2 border-white"></div>
<div class="rounded-full h-26px w-26px bg-orange-300 border-2 border-white -ml-2"></div>
<div class="rounded-full h-26px w-26px bg-teal-300 border-2 border-white -ml-2"></div>
<div class="rounded-full h-26px w-26px bg-orange-300 border-2 border-white -ml-2"></div>
<div class="rounded-full h-26px w-26px bg-teal-300 border-2 border-white -ml-2"></div>
<div
class="rounded-full h-26px min-w-26px bg-gray-200 border-2 border-white -ml-2 flex items-center justify-center text-xs">
99+
</div>
<div class="grid grid-cols-[4rem_1fr_24px] py-15px px-16px border-b-[1px] border-b-solid border-b-[#e5e7eb]">
<div class="h-full flex items-center text-14px">我的消息</div>
<div @click="toMessage" class="h-full flex items-center justify-end overflow-hidden">
<n-avatar
v-if="applyList.length > 0"
:class="index > 0 ? '-ml-2' : ''"
v-for="(item, index) in applyList.splice(0, 6)"
:id="item.applyId"
round
size="small"
:src="
avatarSrc(
groupStore.getUserInfo(isCurrentUser(item.receiverId) ? item.receiverId : item.senderId)?.avatar!
)
" />
</div>
<div class="h-full flex justify-end items-center">
<div @click="toMessage" class="h-full flex justify-end items-center">
<img src="@/assets/mobile/friend/right-arrow.webp" class="block h-20px" alt="" />
</div>
</div>
<!-- <n-tabs class="px-16px" default-value="mutual-attention" justify-content="start" type="line">
<n-tab-pane name="mutual-attention" tab="互相关注">
</n-tab-pane>
<n-tab-pane name="follow" tab="关注"> 关注 </n-tab-pane>
<n-tab-pane name="fans" tab="粉丝"> 粉丝 </n-tab-pane>
<n-tab-pane name="group-chat" tab="群聊"> 群聊 </n-tab-pane>
</n-tabs> -->
<n-tabs type="segment" animated class="mt-4px p-[4px_10px_0px_8px]">
<n-tab-pane name="1" tab="好友">
<n-collapse :display-directive="'show'" accordion :default-expanded-names="['1']">
@@ -179,13 +172,23 @@ import NavBar from '#/layout/navBar/index.vue'
import addFriendIcon from '@/assets/mobile/chat-home/add-friend.webp'
import groupChatIcon from '@/assets/mobile/chat-home/group-chat.webp'
import { MittEnum, OnlineEnum, RoomTypeEnum } from '@/enums'
import { useMessage } from '@/hooks/useMessage.ts'
import { useMitt } from '@/hooks/useMitt.ts'
import router from '@/router'
import { useContactStore } from '@/stores/contacts.ts'
import { useGroupStore } from '@/stores/group'
import { useUserStore } from '@/stores/user'
import { useUserStatusStore } from '@/stores/userStatus'
import { AvatarUtils } from '@/utils/AvatarUtils'
onMounted(async () => {
try {
await contactStore.getApplyPage(false)
} catch (error) {
console.log('请求好友申请列表失败')
}
})
/**
* 渲染图片图标的函数工厂
* @param {string} src - 图标图片路径
@@ -230,8 +233,24 @@ const shrinkStatus = ref(false)
const groupStore = useGroupStore()
const contactStore = useContactStore()
const userStatusStore = useUserStatusStore()
const userStore = useUserStore()
const { stateList } = storeToRefs(userStatusStore)
//好友申请列表(只筛选好友申请消息)
const applyList = computed(() => {
return contactStore.requestFriendsList.filter((item) => item.type === 2)
})
const avatarSrc = (url: string) => AvatarUtils.getAvatarUrl(url)
const isCurrentUser = (uid: string) => {
return uid === userStore.userInfo!.uid
}
const toMessage = () => {
router.push('/mobile/mobileMy/myMessages')
}
/** 群聊列表 */
const groupChatList = computed(() => {
return [...groupStore.groupDetails].sort((a, b) => {
@@ -261,23 +280,35 @@ watchEffect(() => {
})
})
const { preloadChatRoom } = useMessage()
/**
*
* @param uid 群聊id或好友uid
* @param type 1 群聊 2 单聊
*/
const handleClick = (uid: string, type: number) => {
const handleClick = async (id: string, type: number) => {
detailsShow.value = true
activeItem.value = uid
activeItem.value = id
const data = {
context: {
type: type,
uid: uid
uid: id
},
detailsShow: detailsShow.value
}
useMitt.emit(MittEnum.DETAILS_SHOW, data)
router.push('/mobile/mobileMy/friendInfo')
if (type === 1) {
try {
await preloadChatRoom(id)
router.push(`/mobile/chatRoom/chatMain`)
} catch (error) {
console.error(error)
}
} else {
router.push(`/mobile/mobileFriends/friendInfo/${id}`)
}
}
// todo 需要循环数组来展示分组
const showMenu = (event: MouseEvent) => {
+22 -119
View File
@@ -11,9 +11,20 @@
<template #container="{ height }">
<div :style="{ height: height + 'px' }" class="z-2 flex flex-col absolute overflow-auto min-h-70vh w-full">
<div class="flex flex-col p-20px gap-20px">
<!-- <n-avatar :size="40" fallback-src="/logo.png" round /> -->
<MyMessageItem v-for="item in fakeData" :key="item.id" :message="item" />
<div class="flex flex-col p-[10px_20px_0px_20px] gap-20px">
<CommunityTab
:customHeight="height - 55"
@update="onUpdate"
:options="tabOptions"
active-tab-name="friend-message">
<template #friend-message>
<MobileApplyList :close-header="true" type="friend" :custom-height="height - 50" />
</template>
<template #group-message>
<MobileApplyList :close-header="true" type="group" :custom-height="height - 50" />
</template>
</CommunityTab>
</div>
</div>
</template>
@@ -21,127 +32,19 @@
</template>
<script setup lang="ts">
import type { Ref } from 'vue'
type MessageType = 'follow' | 'like' | 'comment' | 'thumbs' | 'system' | 'reply'
interface Message {
id: number
content: string
type: MessageType
const onUpdate = (newTab: string) => {
console.log('已更新:', newTab)
}
const fakeData: Ref<Message[]> = computed(() => [
{ id: 1, content: '关注了你,想了解更多你的动态', type: 'follow' },
{ id: 2, content: '点赞了你的动态「今天天气真好」', type: 'like' },
const tabOptions = reactive([
{
id: 3,
content:
'评论了你的动态:我觉得这个观点非常有意思评论了你的动态:我觉得这个观点非常有意思评论了你的动态:我觉得这个观点非常有意思',
type: 'comment'
tab: '好友消息',
name: 'friend-message'
},
{ id: 4, content: '给你发送了一个赞', type: 'thumbs' },
{ id: 5, content: '系统消息:你有新的好友推荐', type: 'system' },
{ id: 6, content: '关注了你,快去看看他的主页吧', type: 'follow' },
{ id: 7, content: '点赞了你的动态「刚刚完成的项目分享」', type: 'like' },
{ id: 8, content: '评论了你的动态:这个方法真的可行吗?', type: 'comment' },
{ id: 9, content: '给你发送了一个赞', type: 'thumbs' },
{ id: 10, content: '系统消息:你的设置已成功保存', type: 'system' },
{
id: 11,
content:
'回复了你的评论:我也遇到过类似问题,建议试试这个方法我也遇到过类似问题,建议试试这个方法我也遇到过类似问题,建议试试这个方法我也遇到过类似问题,建议试试这个方法',
type: 'reply'
},
{ id: 12, content: '回复了你的动态:哈哈,这个真的太搞笑了', type: 'reply' },
{ id: 13, content: '关注了你,希望能看到更多你的分享', type: 'follow' },
{ id: 14, content: '点赞了你的动态「新年快乐!」', type: 'like' },
{ id: 15, content: '评论了你的动态:这个思路很新颖,我很喜欢', type: 'comment' },
{ id: 16, content: '给你发送了一个赞', type: 'thumbs' },
{ id: 17, content: '系统消息:你已达到每日活跃任务', type: 'system' },
{ id: 18, content: '回复了你的评论:你的建议我已经采纳了,谢谢!', type: 'reply' },
{ id: 19, content: '关注了你,想看看你的最近分享', type: 'follow' },
{ id: 20, content: '点赞了你的动态「今日学习笔记」', type: 'like' },
{ id: 21, content: '关注了你', type: 'follow' },
{ id: 22, content: '点赞了你的动态', type: 'like' },
{ id: 23, content: '评论了你的动态', type: 'comment' },
{ id: 24, content: '给你发送了一个赞', type: 'thumbs' },
{ id: 25, content: '系统消息', type: 'system' },
{ id: 26, content: '关注了你', type: 'follow' },
{ id: 27, content: '点赞了你的动态', type: 'like' },
{ id: 28, content: '评论了你的动态', type: 'comment' },
{ id: 29, content: '给你发送了一个赞', type: 'thumbs' },
{ id: 30, content: '系统消息', type: 'system' },
{ id: 31, content: '关注了你', type: 'follow' },
{ id: 32, content: '点赞了你的动态', type: 'like' },
{ id: 33, content: '评论了你的动态', type: 'comment' },
{ id: 34, content: '给你发送了一个赞', type: 'thumbs' },
{ id: 35, content: '系统消息', type: 'system' },
{ id: 36, content: '关注了你', type: 'follow' },
{ id: 37, content: '点赞了你的动态', type: 'like' },
{ id: 38, content: '评论了你的动态', type: 'comment' },
{ id: 39, content: '给你发送了一个赞', type: 'thumbs' },
{ id: 40, content: '系统消息', type: 'system' },
{ id: 41, content: '关注了你', type: 'follow' },
{ id: 42, content: '点赞了你的动态', type: 'like' },
{ id: 43, content: '评论了你的动态', type: 'comment' },
{ id: 44, content: '给你发送了一个赞', type: 'thumbs' },
{ id: 45, content: '系统消息', type: 'system' },
{ id: 46, content: '关注了你', type: 'follow' },
{ id: 47, content: '点赞了你的动态', type: 'like' },
{ id: 48, content: '评论了你的动态', type: 'comment' },
{ id: 49, content: '给你发送了一个赞', type: 'thumbs' },
{ id: 50, content: '系统消息', type: 'system' },
{ id: 51, content: '关注了你', type: 'follow' },
{ id: 52, content: '点赞了你的动态', type: 'like' },
{ id: 53, content: '评论了你的动态', type: 'comment' },
{ id: 54, content: '给你发送了一个赞', type: 'thumbs' },
{ id: 55, content: '系统消息', type: 'system' },
{ id: 56, content: '关注了你', type: 'follow' },
{ id: 57, content: '点赞了你的动态', type: 'like' },
{ id: 58, content: '评论了你的动态', type: 'comment' },
{ id: 59, content: '给你发送了一个赞', type: 'thumbs' },
{ id: 60, content: '系统消息', type: 'system' },
{ id: 61, content: '关注了你', type: 'follow' },
{ id: 62, content: '点赞了你的动态', type: 'like' },
{ id: 63, content: '评论了你的动态', type: 'comment' },
{ id: 64, content: '给你发送了一个赞', type: 'thumbs' },
{ id: 65, content: '系统消息', type: 'system' },
{ id: 66, content: '关注了你', type: 'follow' },
{ id: 67, content: '点赞了你的动态', type: 'like' },
{ id: 68, content: '评论了你的动态', type: 'comment' },
{ id: 69, content: '给你发送了一个赞', type: 'thumbs' },
{ id: 70, content: '系统消息', type: 'system' },
{ id: 71, content: '关注了你', type: 'follow' },
{ id: 72, content: '点赞了你的动态', type: 'like' },
{ id: 73, content: '评论了你的动态', type: 'comment' },
{ id: 74, content: '给你发送了一个赞', type: 'thumbs' },
{ id: 75, content: '系统消息', type: 'system' },
{ id: 76, content: '关注了你', type: 'follow' },
{ id: 77, content: '点赞了你的动态', type: 'like' },
{ id: 78, content: '评论了你的动态', type: 'comment' },
{ id: 79, content: '给你发送了一个赞', type: 'thumbs' },
{ id: 80, content: '系统消息', type: 'system' },
{ id: 81, content: '关注了你', type: 'follow' },
{ id: 82, content: '点赞了你的动态', type: 'like' },
{ id: 83, content: '评论了你的动态', type: 'comment' },
{ id: 84, content: '给你发送了一个赞', type: 'thumbs' },
{ id: 85, content: '系统消息', type: 'system' },
{ id: 86, content: '关注了你', type: 'follow' },
{ id: 87, content: '点赞了你的动态', type: 'like' },
{ id: 88, content: '评论了你的动态', type: 'comment' },
{ id: 89, content: '给你发送了一个赞', type: 'thumbs' },
{ id: 90, content: '系统消息', type: 'system' },
{ id: 91, content: '关注了你', type: 'follow' },
{ id: 92, content: '点赞了你的动态', type: 'like' },
{ id: 93, content: '评论了你的动态', type: 'comment' },
{ id: 94, content: '给你发送了一个赞', type: 'thumbs' },
{ id: 95, content: '系统消息', type: 'system' },
{ id: 96, content: '关注了你', type: 'follow' },
{ id: 97, content: '点赞了你的动态', type: 'like' },
{ id: 98, content: '评论了你的动态', type: 'comment' },
{ id: 99, content: '给你发送了一个赞', type: 'thumbs' },
{ id: 100, content: '系统消息', type: 'system' }
tab: '群聊消息',
name: 'group-message'
}
])
</script>
+2 -2
View File
@@ -2,7 +2,7 @@
<div class="flex flex-col h-full">
<SafeAreaPlaceholder type="layout" direction="top" />
<img src="@/assets/mobile/chat-home/background.webp" class="w-100% fixed top-0" alt="hula" />
<img src="@/assets/mobile/chat-home/background.webp" class="w-100% fixed z-0 top-0" alt="hula" />
<!-- 设置区 -->
<Settings />
@@ -13,7 +13,7 @@
<div ref="measureRef" class="h-full w-full absolute top-0 z-0"></div>
<!-- 动态内容 -->
<div ref="scrollContainer" :style="{ height: tabHeight + 'px' }" class="z-1 overflow-y-auto mt-2 absolute z-3">
<div class="custom-rounded flex px-24px flex-col gap-4 z-1 p-10px mt-4 shadow">
<div class="custom-rounded bg-white flex px-24px flex-col gap-4 z-1 p-10px mt-4">
<CommunityTab
:style="{ height: tabHeight + 'px' }"
:custom-height="tabHeight - 44"
+30 -8
View File
@@ -21,13 +21,15 @@ import NoticeEdit from '#/views/chat-room/notice/NoticeEdit.vue'
import NoticeList from '#/views/chat-room/notice/NoticeList.vue'
import MobileCommunity from '#/views/community/index.vue'
import AddFriends from '#/views/friends/AddFriends.vue'
import ConfirmAddFriend from '#/views/friends/ConfirmAddFriend.vue'
import ConfirmAddGroup from '#/views/friends/ConfirmAddGroup.vue'
import FriendInfo from '#/views/friends/FriendInfo.vue'
import MobileFriendPage from '#/views/friends/index.vue'
import StartGroupChat from '#/views/friends/StartGroupChat.vue'
import MobileMessagePage from '#/views/message/index.vue'
import EditBio from '#/views/my/EditBio.vue'
import EditBirthday from '#/views/my/EditBirthday.vue'
import EditProfile from '#/views/my/EditProfile.vue'
import FriendInfo from '#/views/my/FriendInfo.vue'
import MobileMy from '#/views/my/index.vue'
import MobileQRCode from '#/views/my/MobileQRCode.vue'
import MobileSettings from '#/views/my/MobileSettings.vue'
@@ -190,11 +192,6 @@ const getMobileRoutes = (): Array<RouteRecordRaw> => [
path: 'SimpleBio',
name: 'mobileSimpleBio',
component: SimpleBio
},
{
path: 'friendInfo',
name: 'mobileFriendInfo',
component: FriendInfo
}
]
},
@@ -217,6 +214,21 @@ const getMobileRoutes = (): Array<RouteRecordRaw> => [
path: 'startGroupChat',
name: 'mobileStartGroupChat',
component: StartGroupChat
},
{
path: 'confirmAddFriend',
name: 'mobileConfirmAddFriend',
component: ConfirmAddFriend
},
{
path: 'confirmAddGroup',
name: 'mobileConfirmAddGroup',
component: ConfirmAddGroup
},
{
path: 'friendInfo/:uid',
name: 'mobileFriendInfo',
component: FriendInfo
}
]
}
@@ -318,6 +330,11 @@ const getDesktopRoutes = (): Array<RouteRecordRaw> => [
name: 'loginSetting',
component: () => import('@/views/moreWindow/settings/LoginSetting.vue')
},
{
path: '/notification',
name: 'notification',
component: () => import('@/views/moreWindow/settings/Notification.vue')
},
{
path: '/versatile',
name: 'versatile',
@@ -355,6 +372,12 @@ const getDesktopRoutes = (): Array<RouteRecordRaw> => [
name: 'rtcCall',
component: () => import('@/views/callWindow/index.vue')
},
// 添加合并消息窗口路由
{
path: '/multiMsg',
name: 'multiMsg',
component: () => import('@/views/multiMsgWindow/index.vue')
},
{
path: '/searchFriend',
name: 'searchFriend',
@@ -464,8 +487,7 @@ const router: any = createRouter({
// 在创建路由后,添加全局前置守卫
// 为解决 “已声明‘to’,但从未读取其值” 的问题,将 to 参数改为下划线开头表示该参数不会被使用
router.beforeEach(async (to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) => {
console.log('进入了路由:', from, to)
router.beforeEach(async (to: RouteLocationNormalized, _from: RouteLocationNormalized, next: NavigationGuardNext) => {
// 桌面端直接放行
if (!isMobile) {
return next()
+32
View File
@@ -0,0 +1,32 @@
import { invoke } from '@tauri-apps/api/core'
export type Settings = {
database: {
sqlite_file: string
}
backend: {
base_url: string
ws_url: string
}
youdao: {
app_key: string
app_secret: string
}
tencent: {
api_key: string
secret_id: string
}
}
export type UpdateSettingsParams = {
baseUrl: string
wsUrl: string
}
export const getSettings = async (): Promise<Settings> => {
return await invoke('get_settings')
}
export const updateSettings = async (settings: UpdateSettingsParams) => {
return await invoke('update_settings', { settings })
}
+8 -7
View File
@@ -1,6 +1,7 @@
import { fetch } from '@tauri-apps/plugin-http'
import CryptoJS from 'crypto-js'
import { AppException } from '@/common/exception.ts'
import { getSettings, type Settings } from '@/services/tauriCommand.ts'
import type { TranslateProvider } from './types.ts'
// 有道云翻译接口响应类型
@@ -133,11 +134,10 @@ export const translateText = async (text: string, provider: TranslateProvider =
*/
const youdaoTranslate = async (text: string): Promise<TranslateResult> => {
// 从环境变量获取密钥信息
const appKey = import.meta.env.VITE_YOUDAO_APP_KEY
const appSecret = import.meta.env.VITE_YOUDAO_APP_SECRET
const settings: Settings = await getSettings()
// 检查密钥是否为空
if (!appKey || !appSecret) {
if (!settings.youdao.app_key || !settings.youdao.app_secret) {
throw window.$message?.error('有道翻译API密钥未配置')
}
@@ -145,7 +145,7 @@ const youdaoTranslate = async (text: string): Promise<TranslateResult> => {
const curtime = Math.round(new Date().getTime() / 1000) // 当前时间戳
// 计算签名
const sign = signUtils.getYoudaoSign(text, appKey, salt, curtime, appSecret)
const sign = signUtils.getYoudaoSign(text, settings.youdao.app_key, salt, curtime, settings.youdao.app_secret)
// 发送翻译请求
const response = await fetch('https://openapi.youdao.com/api', {
@@ -157,7 +157,7 @@ const youdaoTranslate = async (text: string): Promise<TranslateResult> => {
q: text, // 待翻译文本
from: 'auto', // 源语言
to: 'zh-CHS', // 目标语言
appKey, // 应用ID
appKey: settings.youdao.app_key, // 应用ID
salt: salt.toString(), // 随机数
sign, // 签名
signType: 'v3', // 签名类型
@@ -190,8 +190,9 @@ const youdaoTranslate = async (text: string): Promise<TranslateResult> => {
*/
const tencentTranslate = async (text: string): Promise<TranslateResult> => {
// 从环境变量获取密钥信息
const secretId = import.meta.env.VITE_TENCENT_SECRET_ID
const secretKey = import.meta.env.VITE_TENCENT_API_KEY
const settings: Settings = await getSettings()
const secretId = settings.tencent.secret_id
const secretKey = settings.tencent.api_key
// 检查密钥是否为空
if (!secretId || !secretKey) {
+68 -13
View File
@@ -323,12 +323,13 @@ export type MessageType = {
/** 消息主体 */
message: MsgType
/** 发送时间 */
sendTime: string
sendTime: number
/** 时间段(可选) */
timeBlock?: string
timeBlock?: number
/** 是否加载中 */
loading?: boolean
uploadProgress?: number
isCheck?: boolean
}
/**
@@ -378,6 +379,16 @@ export type VoiceBody = {
second: number
url: string
}
export type MergeBodyBody = {
messageId: string
uid: string
}
export type MergeBody = {
body: MergeBodyBody[]
content: string[]
}
/** 视频 */
export type VideoBody = {
size: number
@@ -479,16 +490,32 @@ export type MessageReq = {
}
}
/** 申请状态 */
export enum RequestFriendAgreeStatus {
/** 1待审批 */
Waiting = 1,
/** 2同意 */
Agree,
/** 3拒绝 */
Reject,
/** 4忽略 */
Ignore
/** 通知状态 */
export enum RequestNoticeAgreeStatus {
/** 待审批 */
UNTREATED = 0,
/** 同意 */
ACCEPTED,
/** 拒绝 */
REJECTED,
/** 忽略 */
IGNORE
}
/** 通知事件 */
export enum NoticeType {
/** 好友申请 */
FRIEND_APPLY = 1,
/** 好友被申请 */
ADD_ME = 6,
/** 加群申请 */
GROUP_APPLY = 2,
/** 群邀请 */
GROUP_INVITE = 3,
/** 被邀请进群 */
GROUP_INVITE_ME = 7,
/** 移除群成员 */
GROUP_MEMBER_DELETE = 5
}
/** 请求添加好友的列表项 */
@@ -498,7 +525,7 @@ export type RequestFriendItem = {
/** 申请信息 */
msg: string
/** 申请状态 1待审批 2同意 3拒绝 4忽略 */
status: RequestFriendAgreeStatus
status: RequestNoticeAgreeStatus
/** 申请类型 1加好友 */
type: number
/** 申请人uid */
@@ -510,6 +537,32 @@ export type RequestFriendItem = {
/** 会话 ID */
roomId: string
}
export interface NoticeItem {
/** 实体ID */
id?: string
/** 通知类型:1-好友申请;2-群申请;3-群邀请;5-移除群成员;6-好友被申请;7-被邀请进群 */
eventType: number
/** 通知类型 1群聊 2加好友 */
type: number
/** 发起人UID */
senderId: string
/** 接收人UID */
receiverId: string
/** 申请ID */
applyId: string
/** 被操作的人 */
operateId?: string
/** 通知内容 申请时填写的 */
content: string
/** 处理状态:0-未处理;1-已同意;2-已拒绝;3-忽略 */
status: number
/** 是否已读 */
isRead: boolean
/** 创建时间 */
createTime?: number
}
/** 联系人的列表项 */
export type ContactItem = {
/** 在线状态 1在线 2离线 */
@@ -569,6 +622,8 @@ export type SessionItem = {
remark?: string
/** 我的群昵称 */
myName?: string
/** 是否选中(非后端) */
isCheck?: boolean
}
/** 消息已读未读数列表项 */
-16
View File
@@ -104,21 +104,8 @@ class RustWebSocketClient {
async initConnect(): Promise<void> {
try {
const clientId = localStorage.getItem('clientId')
const savedProxy = localStorage.getItem('proxySettings')
let serverUrl = import.meta.env.VITE_WEBSOCKET_URL
// 处理代理设置
if (savedProxy) {
const settings = JSON.parse(savedProxy)
const suffix = settings.wsIp + ':' + settings.wsPort + '/websocket/' + settings.wsSuffix
if (settings.wsType === 'ws' || settings.wsType === 'wss') {
serverUrl = settings.wsType + '://' + suffix
}
}
const params = {
serverUrl,
clientId: clientId || ''
}
@@ -126,9 +113,6 @@ class RustWebSocketClient {
await invoke('ws_init_connection', { params })
// await this.setupEventListener()
// this.isInitialized = true
info('[RustWS] WebSocket 连接初始化成功')
} catch (err) {
error(`[RustWS] 连接初始化失败: ${err}`)
+54 -67
View File
@@ -30,10 +30,10 @@ let isFirstInit = false
// 撤回消息的过期时间
const RECALL_EXPIRATION_TIME = 2 * 60 * 1000 // 2分钟,单位毫秒
// 定义消息数量阈值
const MESSAGE_THRESHOLD = 120
// 定义保留的最新消息数量
const KEEP_MESSAGE_COUNT = 60
// // 定义消息数量阈值
// const MESSAGE_THRESHOLD = 120
// // 定义保留的最新消息数量
// const KEEP_MESSAGE_COUNT = 60
// 创建src/workers/timer.worker.ts
const timerWorker = new Worker(new URL('../workers/timer.worker.ts', import.meta.url))
@@ -69,19 +69,11 @@ export const useChatStore = defineStore(
const recalledMessages = reactive<Map<string, RecalledMessage>>(new Map())
// 存储每条撤回消息的过期定时器
const expirationTimers = new Map<string, boolean>()
const isMsgMultiChoose = ref<boolean>(false)
// 当前聊天室的消息Map计算属性
const currentMessageMap = computed({
get: () => {
const current = messageMap.get(globalStore.currentSession!.roomId)
if (current === undefined) {
messageMap.set(globalStore.currentSession!.roomId, new Map())
}
return messageMap.get(globalStore.currentSession!.roomId)
},
set: (val) => {
messageMap.set(globalStore.currentSession!.roomId, val as Map<string, MessageType>)
}
const currentMessageMap = computed(() => {
return messageMap.get(globalStore.currentSession!.roomId)
})
// 当前聊天室的消息加载状态计算属性
@@ -115,6 +107,11 @@ export const useChatStore = defineStore(
}
})
// 判断是否应该显示“没有更多消息”
const shouldShowNoMoreMessage = computed(() => {
return currentMessageOptions.value?.isLast
})
// 判断当前是否为群聊
const isGroup = computed(() => globalStore.currentSession?.type === RoomTypeEnum.GROUP)
@@ -183,17 +180,17 @@ export const useChatStore = defineStore(
// 当前消息回复
const currentMsgReply = ref<Partial<MessageType>>({})
// 将消息列表转换为数组
// 将消息列表转换为数组并计算时间间隔
const chatMessageList = computed(() => {
return currentMessageMap.value
? [...currentMessageMap.value.values()].sort((a, b) => Number(a.message.id) - Number(b.message.id))
: []
if (!currentMessageMap.value) return []
return [...currentMessageMap.value.values()].sort((a, b) => Number(a.message.id) - Number(b.message.id))
})
const chatMessageListByRoomId = computed(() => (roomId: string) => {
return messageMap.get(roomId)
? [...messageMap.get(roomId)!.values()].sort((a, b) => Number(a.message.id) - Number(b.message.id))
: []
if (!messageMap.get(roomId)) return []
return [...messageMap.get(roomId)!.values()].sort((a, b) => Number(a.message.id) - Number(b.message.id))
})
// 登录之后,加载一次所有会话的消息
@@ -254,7 +251,6 @@ export const useChatStore = defineStore(
try {
if (sessionOptions.isLoading) return
sessionOptions.isLoading = true
console.log('获取会话列表')
const response: any = await invokeWithErrorHandler(TauriCommand.LIST_CONTACTS, undefined, {
customErrorMessage: '获取会话列表失败',
errorType: ErrorType.Network
@@ -356,16 +352,6 @@ export const useChatStore = defineStore(
const current = messageMap.get(msg.message.roomId)
current?.set(msg.message.id, msg)
// 检查消息数量是否超过阈值
if (current && current.size > MESSAGE_THRESHOLD) {
// 获取所有消息ID并按时间排序
const messageIds = Array.from(current.keys())
const messagesToDelete = messageIds.slice(0, messageIds.length - KEEP_MESSAGE_COUNT)
// 删除旧消息
messagesToDelete.forEach((id) => current.delete(id))
}
// 获取用户信息缓存
const uid = msg.fromUser.uid
const cacheUser = groupStore.getUserInfo(uid)
@@ -412,6 +398,10 @@ export const useChatStore = defineStore(
}
}
const clearSessionCheck = () => {
sessionList.value.forEach((item) => (item.isCheck = false))
}
// 过滤掉拉黑用户的发言
const filterUser = (uid: string) => {
for (const messages of messageMap.values()) {
@@ -573,14 +563,6 @@ export const useChatStore = defineStore(
}
}
// 添加一个工具函数来触发消息列表更新
const triggerMessageMapUpdate = () => {
if (currentMessageMap.value) {
const newMap = new Map(currentMessageMap.value)
currentMessageMap.value = newMap
}
}
// 获取撤回消息
const getRecalledMessage = (msgId: string): RecalledMessage | undefined => {
return recalledMessages.get(msgId)
@@ -597,17 +579,20 @@ export const useChatStore = defineStore(
status,
newMsgId,
body,
uploadProgress
uploadProgress,
timeBlock
}: {
msgId: string
status: MessageStatusEnum
newMsgId?: string
body?: any
uploadProgress?: number
timeBlock?: number
}) => {
const msg = currentMessageMap.value?.get(msgId)
if (msg) {
msg.message.status = status
msg.timeBlock = timeBlock
if (newMsgId) {
msg.message.id = newMsgId
}
@@ -652,6 +637,10 @@ export const useChatStore = defineStore(
const index = sessionList.value.findIndex((session) => session.roomId === roomId)
if (index !== -1) {
sessionList.value.splice(index, 1)
if (globalStore.currentSessionRoomId === roomId) {
globalStore.updateCurrentSessionRoomId(sessionList.value[0].roomId)
}
// 删除会话后更新未读计数
nextTick(() => {
updateTotalUnreadCount()
@@ -667,7 +656,6 @@ export const useChatStore = defineStore(
console.log(`[Timeout] 消息ID: ${msgId} 已过期`)
recalledMessages.delete(msgId)
expirationTimers.delete(msgId)
triggerMessageMapUpdate()
} else if (type === 'allTimersCompleted') {
// 所有定时器都完成了,可以安全地清理资源
clearAllExpirationTimers()
@@ -736,38 +724,34 @@ export const useChatStore = defineStore(
const requestRoomId = globalStore.currentSession!.roomId
try {
// 1. 清空当前消息列表
if (currentMessageMap.value) {
currentMessageMap.value.clear()
}
// 1. 清空消息数据 避免竞态条件
const currentMessages = messageMap.get(requestRoomId)
currentMessages?.clear() // 如果Map存在就清空,不存在getPageMsg会自动创建
// 2. 重置消息加载状态
if (currentMessageOptions.value) {
currentMessageOptions.value = {
isLast: false,
isLoading: true,
cursor: ''
}
}
// 2. 重置消息加载状态,强制cursor为空以获取最新消息
messageOptions.set(requestRoomId, {
isLast: false,
isLoading: true,
cursor: ''
})
// 3. 清空回复映射
if (currentReplyMap.value) {
currentReplyMap.value.clear()
}
const currentReplyMapping = replyMapping.get(requestRoomId)
currentReplyMapping?.clear()
// 4. 从服务器获取最新消息(默认20条)
await getMsgList(pageSize)
// 4. 直接调用getPageMsg获取最新消息,强制使用空cursor
await getPageMsg(pageSize, requestRoomId, '')
console.log('[Network] 已重置并刷新当前聊天室的消息列表')
} catch (error) {
console.error('[Network] 重置并刷新消息列表失败:', error)
// 如果获取失败,确保重置加载状态
if (globalStore.currentSession!.roomId === requestRoomId && currentMessageOptions.value) {
currentMessageOptions.value = {
if (globalStore.currentSession!.roomId === requestRoomId) {
messageOptions.set(requestRoomId, {
isLast: false,
isLoading: false,
cursor: ''
}
})
}
}
}
@@ -777,9 +761,9 @@ export const useChatStore = defineStore(
return sessionList.value.filter((session) => session.type === RoomTypeEnum.GROUP)
}
const shouldShowNoMoreMessage = computed(() => {
return currentMessageOptions.value?.isLast
})
const setMsgMultiChoose = (flag: boolean) => {
isMsgMultiChoose.value = flag
}
return {
getMsgIndex,
@@ -822,7 +806,10 @@ export const useChatStore = defineStore(
addSession,
setAllSessionMsgList,
chatMessageListByRoomId,
shouldShowNoMoreMessage
shouldShowNoMoreMessage,
isMsgMultiChoose,
clearSessionCheck,
setMsgMultiChoose
}
},
{
+6 -6
View File
@@ -1,14 +1,14 @@
import { defineStore } from 'pinia'
import { StoresEnum } from '@/enums'
import type { ContactItem, RequestFriendItem } from '@/services/types'
import { RequestFriendAgreeStatus } from '@/services/types'
import type { ContactItem, NoticeItem } from '@/services/types'
import { RequestNoticeAgreeStatus } from '@/services/types'
import { useGlobalStore } from '@/stores/global'
import {
deleteFriend,
getApplyUnreadCount,
getFriendPage,
handleInvite,
requestApplyPage
requestNoticePage
} from '@/utils/ImRequestUtils'
// 定义分页大小常量
export const pageSize = 20
@@ -18,7 +18,7 @@ export const useContactStore = defineStore(StoresEnum.CONTACTS, () => {
/** 联系人列表 */
const contactsList = ref<ContactItem[]>([])
/** 好友请求列表 */
const requestFriendsList = ref<RequestFriendItem[]>([])
const requestFriendsList = ref<NoticeItem[]>([])
/** 联系人列表分页选项 */
const contactsOptions = ref({ isLast: false, isLoading: false, cursor: '' })
@@ -79,7 +79,7 @@ export const useContactStore = defineStore(StoresEnum.CONTACTS, () => {
}
try {
const res = await requestApplyPage({
const res = await requestNoticePage({
pageNo: applyPageOptions.value.pageNo,
pageSize: 30,
cursor: isFresh ? '' : applyPageOptions.value.cursor
@@ -125,7 +125,7 @@ export const useContactStore = defineStore(StoresEnum.CONTACTS, () => {
if (globalStore.currentSelectedContact) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
globalStore.currentSelectedContact.status = RequestFriendAgreeStatus.Agree
globalStore.currentSelectedContact.status = RequestNoticeAgreeStatus.ACCEPTED
}
// 获取最新的未读数
await getApplyUnReadCount()
+9 -2
View File
@@ -206,10 +206,16 @@ export const useGroupStore = defineStore(
return groupDetails.value.find((item: GroupDetailReq) => item.roomId === globalStore.currentSession!.roomId)
})
const getGroupDetailByRoomId = computed(() => (roomId: string) => {
return groupDetails.value.find((item: GroupDetailReq) => item.roomId === roomId)
})
const updateGroupNumber = (roomId: string, totalNum: number, onlineNum: number) => {
const group = groupDetails.value.find((item) => item.roomId === roomId)
group!.memberNum = totalNum
group!.onlineNum = onlineNum
if (group) {
group.memberNum = totalNum
group.onlineNum = onlineNum
}
}
/**
@@ -526,6 +532,7 @@ export const useGroupStore = defineStore(
setGroupDetails,
updateGroupDetail,
groupDetails,
getGroupDetailByRoomId,
updateGroupNumber,
removeGroupDetail,
addGroupDetail,
+12 -2
View File
@@ -37,7 +37,7 @@ export const useSettingStore = defineStore(StoresEnum.SETTING, {
chat: {
sendKey: 'Enter',
isDouble: true,
translate: 'tencent'
translate: 'youdao'
},
shortcuts: getDefaultShortcuts(),
page: {
@@ -50,6 +50,9 @@ export const useSettingStore = defineStore(StoresEnum.SETTING, {
},
screenshot: {
isConceal: false
},
notification: {
messageSound: true
}
}),
actions: {
@@ -98,7 +101,7 @@ export const useSettingStore = defineStore(StoresEnum.SETTING, {
/** 设置发送消息快捷键 */
setSendMessageShortcut(shortcut: string) {
if (!this.chat) {
this.chat = { sendKey: 'Enter', isDouble: true, translate: 'tencent' }
this.chat = { sendKey: 'Enter', isDouble: true, translate: 'youdao' }
}
this.chat.sendKey = shortcut
},
@@ -118,6 +121,13 @@ export const useSettingStore = defineStore(StoresEnum.SETTING, {
this.screenshot = { isConceal: false }
}
this.screenshot.isConceal = isConceal
},
/** 设置消息提示音开关 */
setMessageSoundEnabled(enabled: boolean) {
if (!this.notification) {
this.notification = { messageSound: true }
}
this.notification.messageSound = enabled
}
},
share: {
+5 -1
View File
@@ -18,7 +18,11 @@ export const useUserStore = defineStore(
})
}
return { userInfo, getUserDetailAction }
const isMe = computed(() => (id: string) => {
return userInfo.value?.uid === id
})
return { userInfo, getUserDetailAction, isMe }
},
{
share: {
+1 -1
View File
@@ -59,7 +59,7 @@ abstract class AbstractMessageStrategy implements MessageStrategy {
body: messageBody,
messageMarks: {}
},
sendTime: new Date(currentTime).toISOString(),
sendTime: Date.now(),
loading: false
}
}
+9 -1
View File
@@ -16,4 +16,12 @@ body {
left: 0;
transition: all 0.9s ease;
border-radius: 10px;
}
}
.chat-message-max-width{
max-width: fit-content;
}
.chat-bot-message-gap{
gap: 8px;
}
+8
View File
@@ -27,3 +27,11 @@
.n-message-container.n-message-container--top {
top: var(--safe-area-inset-top);
}
.chat-message-max-width{
max-width: fit-content;
}
.chat-bot-message-gap{
gap: 4px;
}
+2 -1
View File
@@ -37,7 +37,7 @@ svg {
--action-bar-icon-color: #404040;
--action-bar-icon-hover: #e7e7e7;
// 搜索框背景颜色
--search-bg-color: #f3f3f3;
--search-bg-color: #EAEAEA;
// 消息列表样式
--bg-msg-first-child: #eeeeee;
--bg-msg-hover: #f3f3f3;
@@ -129,6 +129,7 @@ svg {
--safe-area-inset-right: 0px;
--safe-area-inset-bottom: 16px;
--safe-area-inset-left: 0px;
--van-dialog-confirm-button-text-color: #169b80;
}
html[data-theme='dark'] {
+12 -8
View File
@@ -19,6 +19,8 @@ declare module 'vue' {
ChatFooter: typeof import('./../components/rightBox/chatBox/ChatFooter.vue')['default']
ChatHeader: typeof import('./../components/rightBox/chatBox/ChatHeader.vue')['default']
ChatMain: typeof import('./../components/rightBox/chatBox/ChatMain.vue')['default']
ChatMsgMultiChoose: typeof import('./../components/rightBox/chatBox/ChatMsgMultiChoose.vue')['default']
ChatMultiMsg: typeof import('./../components/rightBox/chatBox/ChatMultiMsg.vue')['default']
ChatSidebar: typeof import('./../components/rightBox/chatBox/ChatSidebar.vue')['default']
Community: typeof import('./../mobile/components/my/community.vue')['default']
CommunityContent: typeof import('./../mobile/components/community/CommunityContent.vue')['default']
@@ -36,11 +38,13 @@ declare module 'vue' {
InfoPopover: typeof import('./../components/common/InfoPopover.vue')['default']
LoadingSpinner: typeof import('./../components/common/LoadingSpinner.vue')['default']
MeasuredItem: typeof import('./../mobile/components/virtual-scroll/MeasuredItem.vue')['default']
MergeMessage: typeof import('./../components/rightBox/renderMessage/MergeMessage.vue')['default']
MessageContainer: typeof import('./../mobile/components/chat-room/MessageContainer.vue')['default']
MessageContent: typeof import('./../components/rightBox/chatBox/MessageContent.vue')['default']
MobileApplyList: typeof import('./../mobile/components/my/MobileApplyList.vue')['default']
MsgInput: typeof import('./../components/rightBox/MsgInput.vue')['default']
MyMessageItem: typeof import('./../mobile/components/my/MyMessageItem.vue')['default']
NaiveProvider: typeof import('./../components/common/NaiveProvider.vue')['default']
NAlert: typeof import('naive-ui')['NAlert']
NAutoComplete: typeof import('naive-ui')['NAutoComplete']
NAvatar: typeof import('naive-ui')['NAvatar']
NAvatarGroup: typeof import('naive-ui')['NAvatarGroup']
@@ -55,13 +59,13 @@ declare module 'vue' {
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDatePicker: typeof import('naive-ui')['NDatePicker']
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
NDivider: typeof import('naive-ui')['NDivider']
NDrawer: typeof import('naive-ui')['NDrawer']
NDrawerContent: typeof import('naive-ui')['NDrawerContent']
NDropdown: typeof import('naive-ui')['NDropdown']
NEllipsis: typeof import('naive-ui')['NEllipsis']
NEmpty: typeof import('naive-ui')['NEmpty']
NFlex: typeof import('naive-ui')['NFlex']
NFloatButton: typeof import('naive-ui')['NFloatButton']
NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem']
NHighlight: typeof import('naive-ui')['NHighlight']
@@ -80,6 +84,7 @@ declare module 'vue' {
NPopover: typeof import('naive-ui')['NPopover']
NPopselect: typeof import('naive-ui')['NPopselect']
NProgress: typeof import('naive-ui')['NProgress']
NQrCode: typeof import('naive-ui')['NQrCode']
NRadio: typeof import('naive-ui')['NRadio']
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
NResult: typeof import('naive-ui')['NResult']
@@ -88,6 +93,10 @@ declare module 'vue' {
NSkeleton: typeof import('naive-ui')['NSkeleton']
NSpace: typeof import('naive-ui')['NSpace']
NSpin: typeof import('naive-ui')['NSpin']
NSpliter: typeof import('naive-ui')['NSpliter']
NSplitter: typeof import('naive-ui')['NSplitter']
NStep: typeof import('naive-ui')['NStep']
NSteps: typeof import('naive-ui')['NSteps']
NSwitch: typeof import('naive-ui')['NSwitch']
NTab: typeof import('naive-ui')['NTab']
NTabPane: typeof import('naive-ui')['NTabPane']
@@ -111,16 +120,11 @@ declare module 'vue' {
Settings: typeof import('./../mobile/components/my/Settings.vue')['default']
ShareModal: typeof import('./../mobile/components/my/ShareModal.vue')['default']
SmartVirtualList: typeof import('./../mobile/components/virtual-scroll/SmartVirtualList.vue')['default']
SwipContainer: typeof import('./../mobile/components/swipe/SwipContainer.vue')['default']
SwipeContainer: typeof import('./../mobile/components/swipe/SwipeContainer.vue')['default']
SystemMessage: typeof import('./../components/rightBox/renderMessage/special/SystemMessage.vue')['default']
Text: typeof import('./../components/rightBox/renderMessage/Text.vue')['default']
Validation: typeof import('./../components/common/Validation.vue')['default']
VanCell: typeof import('vant/es')['Cell']
VanCellGroup: typeof import('vant/es')['CellGroup']
VanFloatingPopup: typeof import('vant/es')['FloatingPopup']
VanPasswordInput: typeof import('vant/es')['PasswordInput']
VanSwipeCell: typeof import('vant/es')['SwipeCell']
VanUploader: typeof import('vant/es')['Uploader']
Video: typeof import('./../components/rightBox/renderMessage/Video.vue')['default']
VideoCall: typeof import('./../components/rightBox/renderMessage/VideoCall.vue')['default']
VirtualList: typeof import('./../components/common/VirtualList.vue')['default']
+16
View File
@@ -20,3 +20,19 @@ declare namespace Common {
/** 构建时间 */
declare const PROJECT_BUILD_TIME: string
export type ProxySettings = {
apiType: string
apiIp: string
apiPort: string
apiSuffix: string
wsType: string
wsIp: string
wsPort: string
wsSuffix: string
}
export type MsgId = {
msgId: string
fromUid: string
}
+5
View File
@@ -80,6 +80,11 @@ declare namespace STO {
/** 截图时是否隐藏窗口 */
isConceal: boolean
}
/** 消息通知设置 */
notification: {
/** 是否开启消息提示音 */
messageSound: boolean
}
}
/**
+1 -38
View File
@@ -2,21 +2,14 @@ import dayjs from 'dayjs'
import weekday from 'dayjs/plugin/weekday'
import 'dayjs/locale/zh-cn' // 导入中文语言包
import type { ConfigType, Dayjs, OpUnitType } from 'dayjs'
import type { MessageType } from '@/services/types'
// 全局使用语言包
dayjs.locale('zh-cn')
// 设置一周起始位周一
dayjs.extend(weekday)
// 5 分钟 5 * 60 * 1000;
const intervalTime = 300000
// 计数上限 20 条,到达 20 重置
const computedCountMax = 20
// 计数
let computedCount = 0
// 时间格式化为相对文本,仿微信风格
const timeToStr = (time: number) => {
export const timeToStr = (time: number) => {
const sendTime = dayjs(time)
// 计算今天和消息的发送时间间隔多少天
const gapDay = dayjs().endOf('day').diff(sendTime, 'day')
@@ -30,36 +23,6 @@ const timeToStr = (time: number) => {
: dayjs(sendTime).format('dddd HH:mm')
}
// 超过5分钟,或者超过20条消息,就添加展示时间
const checkTimeInterval = (cur: MessageType, pre: MessageType) => {
// 如果有一个超过 5 分钟了或者计数达到 20 条了
if ((pre && cur.message.sendTime - pre.message.sendTime > intervalTime) || computedCount >= computedCountMax) {
// 重置计数
computedCount = 0
// 返回时间标记
return { ...cur, timeBlock: timeToStr(cur.message.sendTime) }
} else {
// 时间间隔很短的就累计计数
computedCount += 1
return cur
}
}
export const computedTimeBlock = (list: MessageType[], needFirst = true) => {
if (!list || list.length === 0) return []
// 是否需要保留 传入 list 第一个,如果是接口拉回来的消息列表就要保留,如果接收到新消息,需要拿当前消息列表最后一个拿来做时间间隔计算的话,就不需要保留第一个
const temp = needFirst ? [list[0]] : []
// 跳过第一个
for (let index = 1, len = list.length; index < len; index++) {
const item = list[index]
// 上个聊天记录
const preItem = list[index - 1]
// 超过20分钟,或者超过50条评论,展示时间
temp.push(checkTimeInterval(item, preItem))
}
return temp
}
/**
*
* @param timestamp
+25 -4
View File
@@ -129,10 +129,10 @@ export async function getUserDetail() {
/**
*
*/
export async function getGroupDetail(groupId: string) {
export async function getGroupDetail(roomId: string) {
return await imRequest({
url: ImUrlEnum.GROUP_DETAIL,
params: { id: groupId }
params: { id: roomId }
})
}
@@ -215,10 +215,10 @@ export async function getBadgesBatch(body: CacheBadgeReq[]) {
})
}
export async function getMsgList(params: any) {
export async function getMsgList(body: { msgIds?: string[]; lastOptTime?: number }) {
return await imRequest({
url: ImUrlEnum.GET_MSG_LIST,
params
body
})
}
@@ -318,6 +318,13 @@ export async function sendAddFriendRequest(body: { targetUid: string; msg: strin
})
}
export async function requestNoticePage(params: { pageSize: number; pageNo: number; cursor: string }) {
return await imRequest({
url: ImUrlEnum.REQUEST_NOTICE_PAGE,
params
})
}
export async function handleInvite(body: { applyId: string; state: number }) {
return await imRequest({
url: ImUrlEnum.HANDLE_INVITE,
@@ -542,3 +549,17 @@ export async function forgetPassword(body: {
body
})
}
export async function mergeMsg(body: { fromRoomId: string; type: number; roomIds: string[]; messageIds: string[] }) {
return await imRequest({
url: ImUrlEnum.MERGE_MSG,
body
})
}
export async function getUserByIds(body: { uidList: string[] }) {
return await imRequest({
url: ImUrlEnum.GET_USER_BY_IDS,
body
})
}
+1 -5
View File
@@ -8,11 +8,7 @@
:current-label="WebviewWindow.getCurrent().label" />
<!-- 标题 -->
<p
class="absolute-x-center h-fit pt-6px text-(13px [--text-color]) select-none cursor-default"
data-tauri-drag-region>
申请加好友
</p>
<p class="absolute-x-center h-fit pt-6px text-(13px [--text-color]) select-none cursor-default">申请加好友</p>
<!-- 内容区域 -->
<div class="bg-[--bg-edit] w-380px h-full box-border flex flex-col">
+16 -5
View File
@@ -107,6 +107,8 @@
<script setup lang="ts">
import { lightTheme } from 'naive-ui'
import router from '@/router'
import { updateSettings } from '@/services/tauriCommand'
import type { ProxySettings } from '@/typings/global'
const apiOptions = [
{
@@ -192,7 +194,7 @@ const handleSave = async () => {
apiType: savedProxy.apiType,
apiIp: savedProxy.apiIp,
apiPort: savedProxy.apiPort,
apiSuffix: savedProxy.apiSuffix
apiSuffix: savedProxy.apiSuffix ? '/' + savedProxy.apiSuffix : ''
}
} else {
//
@@ -201,20 +203,29 @@ const handleSave = async () => {
wsType: savedProxy.wsType,
wsIp: savedProxy.wsIp,
wsPort: savedProxy.wsPort,
wsSuffix: savedProxy.wsSuffix
wsSuffix: savedProxy.wsSuffix ? '/' + savedProxy.wsSuffix : ''
}
}
const settings = JSON.stringify(proxySettings)
localStorage.setItem('proxySettings', settings)
await updateTauriSettings(proxySettings)
console.log('settings', proxySettings)
window.$message.success('网络设置保存成功')
setTimeout(() => {
location.reload()
}, 1000)
} catch (error) {
window.$message.error('网络设置失败:' + error)
}
}
const updateTauriSettings = async (proxySettings: ProxySettings) => {
const baseUrl =
proxySettings.apiType + '://' + proxySettings.apiIp + ':' + proxySettings.apiPort + proxySettings.apiSuffix
const wsUrl = proxySettings.wsType + '://' + proxySettings.wsIp + ':' + proxySettings.wsPort + proxySettings.wsSuffix
await updateSettings({ baseUrl, wsUrl }).catch((err) => {
window.$message.error('网络设置失败:' + err)
})
}
</script>
<style scoped lang="scss">
@@ -0,0 +1,412 @@
<template>
<n-flex vertical :size="40">
<!-- 消息通知设置 -->
<n-flex vertical class="text-(14px [--text-color])" :size="16">
<span class="pl-10px">提示音</span>
<n-flex class="item p-12px" :size="12" vertical>
<!-- 消息提示音 -->
<n-flex align="center" justify="space-between">
<n-flex vertical :size="8">
<span>消息提示音</span>
<span class="text-(12px #909090)">开启后收到新消息时会播放提示音</span>
</n-flex>
<n-switch size="small" v-model:value="messageSound" />
</n-flex>
</n-flex>
</n-flex>
<!-- 群消息设置 -->
<n-flex vertical class="text-(14px [--text-color])" :size="16">
<n-flex align="center" justify="space-between" class="pl-10px pr-10px">
<span>群消息设置</span>
<n-input v-model:value="searchKeyword" size="small" placeholder="搜索群聊" clearable style="width: 200px">
<template #prefix>
<svg class="size-14px"><use href="#search"></use></svg>
</template>
</n-input>
</n-flex>
<n-flex class="item" :size="0" vertical>
<div v-if="filteredGroupSessions.length === 0" class="text-(12px #909090) text-center py-20px">
{{ searchKeyword ? '未找到匹配的群聊' : '暂无群聊' }}
</div>
<!-- 批量操作栏 -->
<n-flex v-if="filteredGroupSessions.length > 0" align="center" justify="space-between" class="p-12px h-28px">
<n-flex align="center" :size="12">
<n-checkbox
v-model:checked="selectAll"
:indeterminate="selectedSessions.length > 0 && selectedSessions.length < filteredGroupSessions.length"
@update:checked="handleSelectAll">
全选 ({{ selectedSessions.length }}/{{ filteredGroupSessions.length }})
</n-checkbox>
</n-flex>
<n-flex v-if="selectedSessions.length > 0" align="center" :size="8">
<span class="text-(12px #909090)">批量设置:</span>
<n-button
size="small"
type="primary"
secondary
:disabled="isProcessing"
@click="batchSetNotification('allow')">
允许提醒
</n-button>
<n-button size="small" secondary :disabled="isProcessing" @click="batchSetNotification('mute')">
免打扰
</n-button>
<n-button
size="small"
type="error"
secondary
:disabled="isProcessing"
@click="batchSetNotification('shield')">
屏蔽
</n-button>
</n-flex>
</n-flex>
<!-- 进度条显示 -->
<n-flex v-if="isProcessing" vertical :size="12" class="p-12px">
<n-flex align="center" justify="space-between">
<span class="text-(12px #909090)">正在处理{{ processedCount }}/{{ totalCount }}</span>
<span class="text-(12px #909090)">{{ progress }}%</span>
</n-flex>
<n-progress
type="line"
:color="'#13987f'"
:rail-color="'#13987f30'"
:percentage="progress"
:show-indicator="false" />
</n-flex>
<span v-if="filteredGroupSessions.length > 0" class="w-full h-1px bg-[--line-color] block"></span>
<n-scrollbar
style="max-height: 420px; padding: 0 12px; box-sizing: border-box"
:style="{ pointerEvents: isDropdownShow ? 'none' : 'auto' }">
<template v-for="(session, index) in filteredGroupSessions" :key="session.roomId">
<n-flex align="center" justify="space-between" class="py-14px">
<n-flex align="center" :size="12">
<n-checkbox
:checked="selectedSessions.includes(session.roomId)"
@update:checked="(checked) => handleSessionSelect(session.roomId, checked)" />
<img
:src="AvatarUtils.getAvatarUrl(session.avatar) || '/imgs/avatar.png'"
:alt="session.name"
class="w-32px h-32px rounded-6px object-cover" />
<n-flex vertical :size="2">
<span class="text-14px">{{ session.name }}</span>
</n-flex>
</n-flex>
<n-dropdown
:options="getNotificationOptions(session)"
@select="(key) => handleNotificationChange(session, key)"
trigger="click"
:scrollable="false"
@update:show="(show) => (isDropdownShow = show)">
<n-button size="small" :color="'#13987f'" text class="text-(12px [--text-color])">
{{ getNotificationStatusText(session) }}
</n-button>
</n-dropdown>
</n-flex>
<span v-if="index < filteredGroupSessions.length - 1" class="w-full h-1px bg-[--line-color] block"></span>
</template>
</n-scrollbar>
</n-flex>
</n-flex>
</n-flex>
</template>
<script setup lang="ts">
import { NotificationTypeEnum, RoomTypeEnum } from '@/enums'
import type { SessionItem } from '@/services/types'
import { useChatStore } from '@/stores/chat'
import { useSettingStore } from '@/stores/setting'
import { AvatarUtils } from '@/utils/AvatarUtils'
import { notification, shield } from '@/utils/ImRequestUtils'
const settingStore = useSettingStore()
const chatStore = useChatStore()
//
const searchKeyword = ref('')
//
const selectedSessions = ref<string[]>([])
//
const isProcessing = ref(false)
const progress = ref(0)
const processedCount = ref(0)
const totalCount = ref(0)
const processingResults = ref<{ roomId: string; name: string; success: boolean; error?: string }[]>([])
//
const isDropdownShow = ref(false)
const messageSound = computed({
get: () => settingStore.notification.messageSound,
set: (value: boolean) => {
settingStore.setMessageSoundEnabled(value)
}
})
//
const groupSessions = computed(() => {
return chatStore.sessionList.filter((session) => session.type === RoomTypeEnum.GROUP)
})
//
const filteredGroupSessions = computed(() => {
if (!searchKeyword.value) {
return groupSessions.value
}
return groupSessions.value.filter((session) => session.name.toLowerCase().includes(searchKeyword.value.toLowerCase()))
})
//
const selectAll = computed({
get: () =>
selectedSessions.value.length === filteredGroupSessions.value.length && filteredGroupSessions.value.length > 0,
set: (value: boolean) => {
if (value) {
selectedSessions.value = filteredGroupSessions.value.map((session) => session.roomId)
} else {
selectedSessions.value = []
}
}
})
//
const getNotificationStatusText = (session: SessionItem) => {
if (session.shield) {
return '已屏蔽消息'
}
switch (session.muteNotification) {
case NotificationTypeEnum.RECEPTION:
return '允许消息提醒'
case NotificationTypeEnum.NOT_DISTURB:
return '接收消息但不提醒'
default:
return '允许消息提醒'
}
}
//
const getNotificationOptions = (session: SessionItem) => {
return [
{
label: '允许消息提醒',
key: 'allow',
icon:
!session.shield && session.muteNotification === NotificationTypeEnum.RECEPTION
? () => h('svg', { class: 'size-14px text-#13987f' }, [h('use', { href: '#check-small' })])
: undefined
},
{
label: '接收消息但不提醒',
key: 'mute',
icon:
!session.shield && session.muteNotification === NotificationTypeEnum.NOT_DISTURB
? () => h('svg', { class: 'size-14px text-#13987f' }, [h('use', { href: '#check-small' })])
: undefined
},
{
label: '屏蔽群消息',
key: 'shield',
icon: session.shield
? () => h('svg', { class: 'size-14px text-#13987f' }, [h('use', { href: '#check-small' })])
: undefined
}
]
}
//
const handleSelectAll = (checked: boolean) => {
selectAll.value = checked
}
//
const handleSessionSelect = (roomId: string, checked: boolean) => {
if (checked) {
if (!selectedSessions.value.includes(roomId)) {
selectedSessions.value.push(roomId)
}
} else {
const index = selectedSessions.value.indexOf(roomId)
if (index > -1) {
selectedSessions.value.splice(index, 1)
}
}
}
//
const batchSetNotification = async (type: string) => {
if (selectedSessions.value.length === 0) {
window.$message?.warning('请先选择要设置的群聊')
return
}
//
isProcessing.value = true
progress.value = 0
processedCount.value = 0
totalCount.value = selectedSessions.value.length
processingResults.value = []
try {
for (let i = 0; i < selectedSessions.value.length; i++) {
const roomId = selectedSessions.value[i]
const session = groupSessions.value.find((s) => s.roomId === roomId)
if (!session) {
processingResults.value.push({
roomId,
name: '未知群聊',
success: false,
error: '找不到群聊信息'
})
} else {
try {
await handleNotificationChange(session, type)
processingResults.value.push({
roomId,
name: session.name,
success: true
})
} catch (error: any) {
processingResults.value.push({
roomId,
name: session.name,
success: false,
error: error.message || '设置失败'
})
}
}
//
processedCount.value = i + 1
progress.value = Math.round(((i + 1) / selectedSessions.value.length) * 100)
}
//
const successCount = processingResults.value.filter((r) => r.success).length
const failCount = processingResults.value.length - successCount
const typeText =
{
allow: '允许消息提醒',
mute: '免打扰',
shield: '屏蔽'
}[type] || '未知'
if (failCount === 0) {
window.$message?.success(`批量设置完成!${successCount} 个群聊已设置为${typeText}`)
} else {
window.$message?.warning(`批量设置完成:${successCount} 个成功,${failCount} 个失败`)
}
//
selectedSessions.value = []
} catch (error) {
console.error('批量设置失败:', error)
window.$message?.error('批量设置失败,请重试')
} finally {
//
setTimeout(() => {
isProcessing.value = false
}, 1500)
}
}
//
const handleNotificationChange = async (session: SessionItem, key: string) => {
try {
switch (key) {
case 'allow':
//
if (session.shield) {
await shield({
roomId: session.roomId,
state: false
})
chatStore.updateSession(session.roomId, { shield: false })
}
await notification({
roomId: session.roomId,
type: NotificationTypeEnum.RECEPTION
})
chatStore.updateSession(session.roomId, {
muteNotification: NotificationTypeEnum.RECEPTION
})
window.$message?.success('已允许消息提醒')
break
case 'mute':
//
if (session.shield) {
await shield({
roomId: session.roomId,
state: false
})
chatStore.updateSession(session.roomId, { shield: false })
}
await notification({
roomId: session.roomId,
type: NotificationTypeEnum.NOT_DISTURB
})
chatStore.updateSession(session.roomId, {
muteNotification: NotificationTypeEnum.NOT_DISTURB
})
//
chatStore.updateTotalUnreadCount()
window.$message?.success('已设置接收消息但不提醒')
break
case 'shield':
await shield({
roomId: session.roomId,
state: !session.shield
})
chatStore.updateSession(session.roomId, {
shield: !session.shield
})
window.$message?.success(session.shield ? '已取消屏蔽' : '已屏蔽消息')
break
}
} catch (error) {
console.error('设置群消息通知失败:', error)
window.$message?.error('设置失败,请重试')
}
}
//
watch(searchKeyword, () => {
selectedSessions.value = []
})
</script>
<style scoped lang="scss">
.item {
@apply bg-[--bg-setting-item] rounded-12px size-full box-border border-(solid 1px [--line-color]) custom-shadow;
}
.n-scrollbar > .n-scrollbar-rail.n-scrollbar-rail--vertical > .n-scrollbar-rail__scrollbar,
.n-scrollbar + .n-scrollbar-rail.n-scrollbar-rail--vertical > .n-scrollbar-rail__scrollbar {
left: -2px;
}
</style>
+8 -3
View File
@@ -12,15 +12,20 @@ const sideOptions = ref<OPT.L.SettingSide[]>([
icon: 'setting-config'
},
{
url: '/manageStore',
label: '存储管理',
icon: 'mini-sd-card'
url: '/notification',
label: '消息通知',
icon: 'remind'
},
{
url: '/shortcut',
label: '快捷键管理',
icon: 'enter-the-keyboard'
},
{
url: '/manageStore',
label: '存储管理',
icon: 'mini-sd-card'
},
{
url: '/loginSetting',
label: '登录设置',
+66
View File
@@ -0,0 +1,66 @@
<template>
<div>
<ActionBar :shrink="false" :max-w="false" />
<div v-for="msg in msgs" :key="msg.message.id" class="flex ml-8 py-2">
<n-avatar
class="rounded-8px flex-shrink-0 mr-2"
:size="30"
:src="AvatarUtils.getAvatarUrl(computedUser(msg.fromUser.uid)!.avatar)" />
<div class="flex-1">
<div>{{ computedUser(msg.fromUser.uid)?.name }}</div>
<p class="w-100% whitespace-pre-wrap break-words">{{ msg.message.body.content }}</p>
<n-divider class="!my-2" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useWindow } from '@/hooks/useWindow'
import type { MessageType, UserItem } from '@/services/types'
import { AvatarUtils } from '@/utils/AvatarUtils'
import { getMsgList, getUserByIds } from '@/utils/ImRequestUtils'
type Msg = {
msgId: string
fromUid: string
}
const { getWindowPayload } = useWindow()
const choosedMsgs = ref<Msg[]>([])
const msgs = ref<MessageType[]>()
const users = ref<UserItem[]>()
const route = useRoute()
const computedUser = computed(() => (uid: string) => {
return users.value?.find((user) => user.uid === uid)
})
const getAllMsg = async () => {
const msgIds = choosedMsgs.value.map((msg) => msg.msgId)
await getMsgList({ msgIds }).then((data) => {
msgs.value = data
})
}
const getAllUserInfo = async () => {
const uids = choosedMsgs.value.map((msg) => msg.fromUid)
await getUserByIds({ uidList: uids }).then((data) => {
users.value = data
})
}
onMounted(() => {
getWindowPayload<Msg[]>(route.query.key as string)
.then(async (data) => {
choosedMsgs.value = data
await getAllMsg()
await getAllUserInfo()
})
.catch((e) => {
console.error(e)
})
})
</script>
<style scoped lang="scss"></style>
+1
View File
@@ -1,5 +1,6 @@
{
"compilerOptions": {
"target": "ES2022",
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
+33 -11
View File
@@ -4,6 +4,7 @@ import terser from '@rollup/plugin-terser'
import UnoCSS from '@unocss/vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import { internalIpV4 } from 'internal-ip'
import postcsspxtorem from 'postcss-pxtorem'
import AutoImport from 'unplugin-auto-import/vite' //自动导入
import { NaiveUiResolver, VantResolver } from 'unplugin-vue-components/resolvers'
@@ -16,12 +17,35 @@ import { getRootPath, getSrcPath } from './build/config/getPath'
const packageJson = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf-8'))
const dependencies = Object.keys(packageJson.dependencies || {})
// 预先获取本地IP
const rawIP = await internalIpV4()
// https://vitejs.dev/config/
/**! 不需要优化前端打包(如开启gzip) */
export default defineConfig(({ mode }: ConfigEnv) => {
// 获取当前环境的配置,如何设置第三个参数则加载所有变量,而不是以"VITE_"前缀的变量
const config = loadEnv(mode, process.cwd(), '')
const host = config.TAURI_DEV_HOST
const isPC =
config.TAURI_ENV_PLATFORM === 'windows' ||
config.TAURI_ENV_PLATFORM === 'darwin' ||
config.TAURI_ENV_PLATFORM === 'linux'
// 根据平台决定host地址
const host = (() => {
if (isPC) {
return '127.0.0.1'
}
// 移动端逻辑:检查是否为有效的内网IP地址
if (rawIP && !rawIP.endsWith('.0') && !rawIP.endsWith('.255')) {
return rawIP // 有效IP且非网段/广播地址
}
// 无效IP或特殊地址的情况
return config.TAURI_ENV_PLATFORM === 'android' ? '0.0.0.0' : '127.0.0.1'
})()
console.log('Host:', host)
return {
resolve: {
@@ -90,11 +114,18 @@ export default defineConfig(({ mode }: ConfigEnv) => {
})
],
build: {
target: ['chrome87', 'edge88', 'firefox78', 'safari14'], // 兼容低版本浏览器
// 设置兼容低版本浏览器的目标
target: ['chrome87', 'edge88', 'firefox78', 'safari14'],
cssCodeSplit: true, // 启用 CSS 代码拆分
minify: 'terser', // 指定使用哪种混淆器
// chunk 大小警告的限制(kb)
chunkSizeWarningLimit: 1200,
// esbuild配置,解决低版本浏览器兼容性问题
esbuild: {
supported: {
'top-level-await': false
}
},
// 分包配置
rollupOptions: {
output: {
@@ -123,15 +154,6 @@ export default defineConfig(({ mode }: ConfigEnv) => {
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
//配置跨域
proxy: {
'/api': {
// “/api” 以及前置字符串会被替换为真正域名
target: config.VITE_SERVICE_URL, // 请求域名
changeOrigin: true, // 是否跨域
rewrite: (path) => path.replace(/^\/api/, '')
}
},
hmr: {
protocol: 'ws',
host: host,