perf(mobile): ⚡ perfect mobile launch page and some other issues (#352)
* fix(common): 🐛 fix user presence issues in chat lists * feat(mobile): ✨ added long-press event functionality * fix(view): 🐛 read notifications correctly * perf(status): ⚡ optimize online state switching * fix(android): 🐛 fix the inconsistency issue of Android startup page images * fix(mobile): 🐛 the number of notifications is removed when clicked * feat(mobile): ✨ improve long-press event functionality * fix(mobile): 🐛 connect to the top and delete the session * fix(mobile): 🐛 delete friends, add friends logic * fix(mobile): 🐛 delete the friend back page * feat(mobile): ✨ add long-press menu in mobile chatroom * fix(status): 🐛 fix online status display issues * fix(Android): 🐛 fix android startup page does not disappear --------- Co-authored-by: 卡仔 <1271013637@qq.com> Co-authored-by: 乾乾 <1046762075@qq.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
|
||||
<p align="center">
|
||||
<img width="350px" height="150px" src="src/assets/logo/hula.png"/>
|
||||
<img width="350px" height="150px" src="public/hula.png"/>
|
||||
</p>
|
||||
|
||||
<p align="center">An instant messaging system built with Tauri, Vite 7, Vue 3, and TypeScript</p>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
<p align="center">
|
||||
<img width="350px" height="150px" src="src/assets/logo/hula.png"/>
|
||||
<img width="350px" height="150px" src="public/hula.png"/>
|
||||
</p>
|
||||
|
||||
<p align="center">一款基于Tauri、Vite 7、Vue 3 和 TypeScript 构建的即时通讯系统</p>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# HuLa 项目入门手册 🚀
|
||||
|
||||
<p align="center">
|
||||
<img width="350px" height="150px" src="../src/assets/logo/hula.png"/>
|
||||
<img width="350px" height="150px" src="../public/hula.png"/>
|
||||
</p>
|
||||
|
||||
<p align="center">基于 Tauri、Vite 7、Vue 3 和 TypeScript 构建的跨平台即时通讯系统完整开发指南</p>
|
||||
|
||||
1
package.json
vendored
1
package.json
vendored
@@ -103,6 +103,7 @@
|
||||
"@vue-office/excel": "^1.7.14",
|
||||
"@vue-office/pdf": "^2.0.10",
|
||||
"@vue-office/pptx": "^1.0.1",
|
||||
"@vueuse/components": "^13.9.0",
|
||||
"colorthief": "^2.6.0",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dayjs": "^1.11.18",
|
||||
|
||||
14
pnpm-lock.yaml
generated
vendored
14
pnpm-lock.yaml
generated
vendored
@@ -80,6 +80,9 @@ importers:
|
||||
'@vue-office/pptx':
|
||||
specifier: ^1.0.1
|
||||
version: 1.0.1(vue-demi@0.14.6(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))
|
||||
'@vueuse/components':
|
||||
specifier: ^13.9.0
|
||||
version: 13.9.0(vue@3.5.22(typescript@5.9.3))
|
||||
colorthief:
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.0
|
||||
@@ -2060,6 +2063,11 @@ packages:
|
||||
'@vue/test-utils@2.4.6':
|
||||
resolution: {integrity: sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==}
|
||||
|
||||
'@vueuse/components@13.9.0':
|
||||
resolution: {integrity: sha512-0DDFpjG3hEEK+3YgSzE/OzOGqpo/KmxcXWzW2YdmgahZvaoUdegn68GmbdcHRJE7CH55dDj13Cz47iN8QoI3jQ==}
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
'@vueuse/core@13.9.0':
|
||||
resolution: {integrity: sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==}
|
||||
peerDependencies:
|
||||
@@ -6434,6 +6442,12 @@ snapshots:
|
||||
js-beautify: 1.15.1
|
||||
vue-component-type-helpers: 2.2.0
|
||||
|
||||
'@vueuse/components@13.9.0(vue@3.5.22(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@vueuse/core': 13.9.0(vue@3.5.22(typescript@5.9.3))
|
||||
'@vueuse/shared': 13.9.0(vue@3.5.22(typescript@5.9.3))
|
||||
vue: 3.5.22(typescript@5.9.3)
|
||||
|
||||
'@vueuse/core@13.9.0(vue@3.5.22(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@types/web-bluetooth': 0.0.21
|
||||
|
||||
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 126 KiB |
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -2806,10 +2806,12 @@ dependencies = [
|
||||
"futures-util",
|
||||
"http 0.2.12",
|
||||
"image 0.25.6",
|
||||
"jni",
|
||||
"lazy_static",
|
||||
"migration",
|
||||
"mime_guess",
|
||||
"moka",
|
||||
"ndk-context",
|
||||
"objc2 0.6.2",
|
||||
"objc2-app-kit 0.3.1",
|
||||
"reqwest 0.11.27",
|
||||
|
||||
@@ -107,6 +107,10 @@ tauri-plugin-safe-area-insets = "0.1.0"
|
||||
tauri-plugin-hula = { path="../tauri-plugin-hula"}
|
||||
tauri-plugin-barcode-scanner = "2.4.0"
|
||||
|
||||
[target."cfg(target_os = \"android\")".dependencies]
|
||||
jni = "0.21"
|
||||
ndk-context = "0.1"
|
||||
|
||||
# 不兼容移动端的依赖
|
||||
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
|
||||
tauri-plugin-autostart = "2"
|
||||
|
||||
@@ -11,6 +11,43 @@ import kotlin.math.roundToInt
|
||||
|
||||
class MainActivity : TauriActivity() {
|
||||
|
||||
private var splashHidden = false
|
||||
private var currentWebView: WebView? = null
|
||||
|
||||
// Rust端通过JNI调用的实例方法
|
||||
@Suppress("unused")
|
||||
fun show() {
|
||||
// Rust端调用显示启动画面(启动时)
|
||||
android.util.Log.i("MainActivity", "✨ Splash show() called from Rust")
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
fun hide() {
|
||||
// Rust端调用隐藏启动画面(应用启动完成)
|
||||
android.util.Log.i("MainActivity", "🎯 Splash hide() called from Rust - setting WebView background")
|
||||
|
||||
runOnUiThread {
|
||||
hideStartupBackground()
|
||||
}
|
||||
}
|
||||
|
||||
private fun hideStartupBackground() {
|
||||
if (!splashHidden) {
|
||||
splashHidden = true
|
||||
android.util.Log.d("MainActivity", "hideStartupBackground called, splashHidden set to true")
|
||||
|
||||
// 使用保存的WebView引用
|
||||
currentWebView?.let {
|
||||
android.util.Log.d("MainActivity", "WebView reference found, setting background")
|
||||
it.setBackgroundColor(0xFFFFFFFF.toInt())
|
||||
window.setBackgroundDrawableResource(android.R.color.transparent)
|
||||
android.util.Log.i("MainActivity", "✅ WebView background set to opaque white")
|
||||
} ?: android.util.Log.e("MainActivity", "❌ WebView reference not found")
|
||||
} else {
|
||||
android.util.Log.w("MainActivity", "hideStartupBackground called but splashHidden already true")
|
||||
}
|
||||
}
|
||||
|
||||
private val requestPermissionLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
|
||||
permissions.entries.forEach { entry ->
|
||||
@@ -55,9 +92,16 @@ class MainActivity : TauriActivity() {
|
||||
override fun onWebViewCreate(webView: WebView) {
|
||||
super.onWebViewCreate(webView)
|
||||
|
||||
// 保存WebView引用
|
||||
currentWebView = webView
|
||||
|
||||
// 初始化 WebView 背景填充
|
||||
webView.setBackgroundColor(0x00000000) // 透明,允许窗口背景延续启动图
|
||||
window.setBackgroundDrawableResource(R.drawable.launch_screen)
|
||||
splashHidden = false
|
||||
|
||||
android.util.Log.i("MainActivity", "WebView created, waiting for Rust hide() call...")
|
||||
|
||||
|
||||
// 监听安全区 Insets 并注入 CSS 变量
|
||||
ViewCompat.setOnApplyWindowInsetsListener(webView) { _, insets ->
|
||||
|
||||
83
src-tauri/gen/android/app/src/main/java/com/hula_ios/app/SplashScreen.kt
vendored
Normal file
83
src-tauri/gen/android/app/src/main/java/com/hula_ios/app/SplashScreen.kt
vendored
Normal file
@@ -0,0 +1,83 @@
|
||||
package com.hula_ios.app
|
||||
|
||||
import android.app.Activity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
|
||||
object SplashScreen {
|
||||
private var overlay: FrameLayout? = null
|
||||
|
||||
@JvmStatic
|
||||
fun show(activity: Activity) {
|
||||
activity.runOnUiThread {
|
||||
val parent = activity.findViewById<ViewGroup>(android.R.id.content) ?: return@runOnUiThread
|
||||
val splashView = overlay ?: FrameLayout(activity).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
setBackgroundResource(R.color.splash_background)
|
||||
isClickable = true
|
||||
isFocusable = true
|
||||
|
||||
val imageView = AppCompatImageView(activity).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
scaleType = ImageView.ScaleType.CENTER_CROP
|
||||
setImageResource(R.drawable.splash)
|
||||
contentDescription = "Splash"
|
||||
}
|
||||
addView(imageView)
|
||||
}.also {
|
||||
overlay = it
|
||||
}
|
||||
|
||||
if (splashView.parent == null) {
|
||||
parent.addView(splashView)
|
||||
}
|
||||
splashView.animate().cancel()
|
||||
splashView.alpha = 1f
|
||||
splashView.visibility = View.VISIBLE
|
||||
|
||||
activity.window.setBackgroundDrawableResource(R.drawable.launch_screen)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun hide(activity: Activity) {
|
||||
activity.runOnUiThread {
|
||||
val parent = activity.findViewById<ViewGroup>(android.R.id.content)
|
||||
val currentOverlay = overlay
|
||||
|
||||
val clearWindow: () -> Unit = {
|
||||
activity.window.setBackgroundDrawableResource(android.R.color.transparent)
|
||||
}
|
||||
|
||||
if (currentOverlay == null) {
|
||||
clearWindow()
|
||||
return@runOnUiThread
|
||||
}
|
||||
|
||||
val removeAction = {
|
||||
currentOverlay.animate().cancel()
|
||||
parent?.removeView(currentOverlay)
|
||||
overlay = null
|
||||
clearWindow()
|
||||
}
|
||||
|
||||
currentOverlay.animate().cancel()
|
||||
currentOverlay.animate()
|
||||
.alpha(0f)
|
||||
.setDuration(250L)
|
||||
.withEndAction(removeAction)
|
||||
.start()
|
||||
|
||||
currentOverlay.postDelayed(removeAction, 400L)
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 15 KiB |
@@ -3,7 +3,8 @@
|
||||
<item android:drawable="@color/splash_background" />
|
||||
<item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:gravity="fill"
|
||||
android:filter="true"
|
||||
android:src="@drawable/splash" />
|
||||
</item>
|
||||
</layer-list>
|
||||
|
||||
2
src-tauri/gen/schemas/acl-manifests.json
vendored
2
src-tauri/gen/schemas/acl-manifests.json
vendored
File diff suppressed because one or more lines are too long
@@ -12,7 +12,6 @@ pub mod room_member_command;
|
||||
pub mod setting_command;
|
||||
pub mod user_command;
|
||||
|
||||
|
||||
// A custom task for setting the state of a setup task
|
||||
#[tauri::command]
|
||||
pub async fn set_complete(
|
||||
@@ -20,16 +19,13 @@ pub async fn set_complete(
|
||||
state: State<'_, AppData>,
|
||||
task: String,
|
||||
) -> Result<(), ()> {
|
||||
tracing::info!("set complete");
|
||||
tracing::info!("set_complete: {}", task);
|
||||
match task.as_str() {
|
||||
"frontend" => *state.frontend_task.lock().await = true,
|
||||
"backend" => *state.backend_task.lock().await = true,
|
||||
_ => panic!("invalid task completed!"),
|
||||
}
|
||||
#[cfg(all(mobile, target_os = "ios"))]
|
||||
if task == "frontend" {
|
||||
crate::mobiles::splash::hide();
|
||||
}
|
||||
// 不再自动隐藏启动画面,由前端页面渲染完成后主动调用 hide_splash_screen
|
||||
tracing::info!("set_complete {}: {:?}", task, state.frontend_task);
|
||||
tracing::info!("set_complete {}: {:?}", task, state.backend_task);
|
||||
Ok(())
|
||||
|
||||
@@ -188,6 +188,7 @@ pub fn get_configuration(app_handle: &AppHandle) -> Result<Settings, config::Con
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
let _ = app_handle;
|
||||
// 读取 base.yaml 内容
|
||||
let base_content = std::str::from_utf8(include_bytes!("../configuration/base.yaml"))
|
||||
.map_err(|e| config::ConfigError::Message(e.to_string()))?;
|
||||
|
||||
@@ -411,6 +411,8 @@ fn get_invoke_handlers() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Se
|
||||
{
|
||||
#[cfg(mobile)]
|
||||
use crate::command::set_complete;
|
||||
#[cfg(mobile)]
|
||||
use crate::mobiles::splash::hide_splash_screen;
|
||||
use crate::command::user_command::{
|
||||
get_user_tokens, save_user_info, update_user_last_opt_time,
|
||||
};
|
||||
@@ -493,5 +495,7 @@ fn get_invoke_handlers() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Se
|
||||
update_settings,
|
||||
#[cfg(mobile)]
|
||||
set_complete,
|
||||
#[cfg(mobile)]
|
||||
hide_splash_screen,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#[cfg(target_os = "ios")]
|
||||
mod ios {
|
||||
mod platform {
|
||||
unsafe extern "C" {
|
||||
fn hula_show_splashscreen();
|
||||
fn hula_hide_splashscreen();
|
||||
@@ -14,16 +14,69 @@ mod ios {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
mod ios {
|
||||
#[cfg(target_os = "android")]
|
||||
mod platform {
|
||||
use jni::{
|
||||
JavaVM,
|
||||
objects::JObject,
|
||||
};
|
||||
|
||||
fn invoke(method: &str) -> Result<(), jni::errors::Error> {
|
||||
let ctx = ndk_context::android_context();
|
||||
let vm = unsafe { JavaVM::from_raw(ctx.vm().cast()) }?;
|
||||
let mut env = vm.attach_current_thread()?;
|
||||
let activity = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
|
||||
|
||||
// 直接调用Activity实例的方法,不使用静态方法
|
||||
let result = env.call_method(
|
||||
&activity,
|
||||
method,
|
||||
"()V",
|
||||
&[],
|
||||
);
|
||||
|
||||
let _ = activity.into_raw();
|
||||
|
||||
result.map(|_| ()).map_err(|err| {
|
||||
if env.exception_check().unwrap_or(false) {
|
||||
let _ = env.exception_describe();
|
||||
let _ = env.exception_clear();
|
||||
}
|
||||
err
|
||||
})
|
||||
}
|
||||
|
||||
pub fn show() {
|
||||
if let Err(err) = invoke("show") {
|
||||
tracing::error!("[Splashscreen] failed to show on Android: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hide() {
|
||||
if let Err(err) = invoke("hide") {
|
||||
tracing::error!("[Splashscreen] failed to hide on Android: {}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "ios", target_os = "android")))]
|
||||
mod platform {
|
||||
pub fn show() {}
|
||||
pub fn hide() {}
|
||||
}
|
||||
|
||||
pub fn show() {
|
||||
ios::show();
|
||||
platform::show();
|
||||
}
|
||||
|
||||
pub fn hide() {
|
||||
ios::hide();
|
||||
platform::hide();
|
||||
}
|
||||
|
||||
/// Tauri command: 隐藏启动画面(由前端调用)
|
||||
#[tauri::command]
|
||||
pub fn hide_splash_screen() -> Result<(), String> {
|
||||
tracing::info!("hide_splash_screen called from frontend");
|
||||
hide();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
</div>
|
||||
<!-- 普通右键菜单 -->
|
||||
<div
|
||||
v-if="showMenu && !(emoji && emoji.length > 0 && showAllEmojis)"
|
||||
v-if="!isMobile() && showMenu && !(emoji && emoji.length > 0 && showAllEmojis)"
|
||||
class="context-menu select-none"
|
||||
:style="{
|
||||
left: `${pos.posX}px`,
|
||||
@@ -77,6 +77,35 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 移动端菜单 -->
|
||||
<div
|
||||
v-if="isMobile() && showMenu && !(emoji && emoji.length > 0 && showAllEmojis)"
|
||||
class="context-menu select-none"
|
||||
:style="{
|
||||
left: `${pos.posX}px`,
|
||||
top: `${pos.posY}px`
|
||||
}">
|
||||
<div
|
||||
v-resize="handleSize"
|
||||
v-if="(visibleMenu && visibleMenu.length > 0) || (visibleSpecialMenu && visibleSpecialMenu.length > 0)"
|
||||
class="max-w-70vw grid grid-cols-5 gap-5px h-auto!">
|
||||
<div
|
||||
@click="handleClick(item)"
|
||||
v-for="(item, index) in visibleMenu"
|
||||
:key="index"
|
||||
class="w-45px h-45px flex justify-center items-center">
|
||||
<div class="flex w-45px flex-col active:bg-gray-200 justify-center items-center max-h-45px">
|
||||
<svg class="w-18px w-18px"><use :href="`#${getMenuItemProp(item, 'icon')}`"></use></svg>
|
||||
<p class="h-24px text-12px">{{ getMenuItemProp(item, 'label') }}</p>
|
||||
<svg v-if="shouldShowArrow(item)" class="arrow-icon w-18px w-18px">
|
||||
<use href="#right"></use>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 二级菜单 -->
|
||||
<div v-if="showSubmenu && activeSubmenu" class="context-submenu" :style="submenuPosition">
|
||||
<div class="menu-list">
|
||||
@@ -102,6 +131,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useContextMenu } from '@/hooks/useContextMenu.ts'
|
||||
import { useViewport } from '@/hooks/useViewport.ts'
|
||||
import { isMobile } from '~/src/utils/PlatformConstants'
|
||||
|
||||
type Props = {
|
||||
content?: Record<string, any>
|
||||
@@ -438,6 +468,25 @@ const shouldShowArrow = (item: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
@mixin menu-item-wrap {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.menu-item-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
// menu-list通用样式
|
||||
@mixin menu-list {
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
@@ -459,6 +508,48 @@ const shouldShowArrow = (item: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
@mixin menu-list-wrap {
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
padding: 5px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
|
||||
.menu-item-wrap {
|
||||
@include menu-item();
|
||||
display: flex;
|
||||
align-items: center;
|
||||
&:hover {
|
||||
background-color: var(--bg-menu-hover);
|
||||
svg {
|
||||
animation: twinkle 0.3s ease-in-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// menu-list通用样式
|
||||
@mixin menu-list-wrap {
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
padding: 5px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 6px;
|
||||
|
||||
.menu-item {
|
||||
@include menu-item();
|
||||
display: flex;
|
||||
align-items: center;
|
||||
&:hover {
|
||||
background-color: var(--bg-menu-hover);
|
||||
svg {
|
||||
animation: twinkle 0.3s ease-in-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
@include menu-item-style();
|
||||
.emoji-container {
|
||||
@@ -491,6 +582,26 @@ const shouldShowArrow = (item: any) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu-list-wrap {
|
||||
display: flex;
|
||||
justify-content: row;
|
||||
flex-wrap: wrap;
|
||||
@include menu-list-wrap();
|
||||
.menu-item-disabled {
|
||||
@include menu-item-wrap();
|
||||
color: var(--disabled-color);
|
||||
svg {
|
||||
color: var(--disabled-color);
|
||||
}
|
||||
}
|
||||
.menu-item-danger {
|
||||
color: #d03553;
|
||||
svg {
|
||||
color: #d03553;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.context-submenu {
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
<n-flex vertical :size="26" class="size-fit box-border rounded-8px relative min-h-[300px] select-none cursor-default">
|
||||
<!-- 背景 -->
|
||||
<img
|
||||
class="absolute rounded-t-8px z-2 top-0 left-0 w-full h-100px object-cover"
|
||||
src="@/assets/img/dispersion-bg.png"
|
||||
class="absolute rounded-t-8px z-2 top-0 left-0 w-full h-100px"
|
||||
:class="
|
||||
groupStore.getUserInfo(uid)?.wearingItemId === '6' ? 'object-contain bg-#e9e9e980 dark:bg-#111' : 'object-cover'
|
||||
"
|
||||
:src="groupStore.getUserInfo(uid)?.wearingItemId === '6' ? '/hula.png' : '/img/dispersion-bg.png'"
|
||||
alt="" />
|
||||
<div class="h-20px"></div>
|
||||
<n-flex vertical :size="20" class="size-full p-10px box-border z-10 relative">
|
||||
@@ -30,9 +33,9 @@
|
||||
<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>
|
||||
:class="[displayActiveStatus === OnlineEnum.ONLINE ? 'bg-#1ab292' : 'bg-#909090']"></div>
|
||||
</template>
|
||||
<span>{{ activeStatus === OnlineEnum.ONLINE ? '在线' : '离线' }}</span>
|
||||
<span>{{ displayActiveStatus === OnlineEnum.ONLINE ? '在线' : '离线' }}</span>
|
||||
</n-popover>
|
||||
</template>
|
||||
|
||||
@@ -176,7 +179,7 @@ import { useSettingStore } from '@/stores/setting'
|
||||
import { useUserStatusStore } from '@/stores/userStatus'
|
||||
import { AvatarUtils } from '@/utils/AvatarUtils'
|
||||
|
||||
const { uid, activeStatus } = defineProps<{
|
||||
const { uid } = defineProps<{
|
||||
uid: string
|
||||
activeStatus?: OnlineEnum
|
||||
}>()
|
||||
@@ -198,6 +201,10 @@ const avatarSrc = computed(() => AvatarUtils.getAvatarUrl(groupStore.getUserInfo
|
||||
const isCurrentUserUid = computed(() => userUid.value === uid)
|
||||
/** 是否是我的好友 */
|
||||
const isMyFriend = computed(() => !!contactStore.contactsList.find((item) => item.uid === uid))
|
||||
// 显示的在线状态
|
||||
const displayActiveStatus = computed(() => {
|
||||
return groupStore.getUserInfo(uid)?.activeStatus ?? OnlineEnum.OFFLINE
|
||||
})
|
||||
|
||||
// 计算当前用户状态图标
|
||||
const statusIcon = computed(() => {
|
||||
@@ -225,7 +232,7 @@ const currentStateTitle = computed(() => {
|
||||
return state.title
|
||||
}
|
||||
}
|
||||
return activeStatus === OnlineEnum.ONLINE ? '在线' : '离线'
|
||||
return displayActiveStatus.value === OnlineEnum.ONLINE ? '在线' : '离线'
|
||||
})
|
||||
|
||||
const openEditInfo = () => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div data-tauri-drag-region class="flex-col-center gap-30px size-full">
|
||||
<n-flex vertical justify="center" :size="20">
|
||||
<div class="logo-container">
|
||||
<img src="@/assets/logo/hula.png" class="logo-image" alt="HuLa Logo" loading="eager" />
|
||||
<img src="/hula.png" class="logo-image" alt="HuLa Logo" loading="eager" />
|
||||
</div>
|
||||
<n-progress
|
||||
type="line"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<!-- 顶部操作栏和显示用户名 -->
|
||||
<main
|
||||
data-tauri-drag-region
|
||||
@@ -7,7 +7,7 @@
|
||||
<Transition name="loading" mode="out-in">
|
||||
<n-flex align="center">
|
||||
<n-avatar
|
||||
:class="['rounded-8px select-none grayscale', { 'grayscale-0': isOnline }]"
|
||||
:class="['rounded-8px select-none', { grayscale: activeItem.type === RoomTypeEnum.SINGLE && !isOnline }]"
|
||||
:size="28"
|
||||
:color="themes.content === ThemeEnum.DARK ? '' : '#fff'"
|
||||
:fallback-src="themes.content === ThemeEnum.DARK ? '/logoL.png' : '/logoD.png'"
|
||||
@@ -27,7 +27,7 @@
|
||||
<template v-if="shouldShowDeleteFriend">
|
||||
<n-flex align="center" :size="6">
|
||||
<!-- 状态图标 -->
|
||||
<img v-if="statusIcon" :src="statusIcon" class="size-18px rounded-50%" alt="" />
|
||||
<img v-if="hasCustomState && statusIcon" :src="statusIcon" class="size-18px rounded-50%" alt="" />
|
||||
<n-badge v-else :color="isOnline ? '#1ab292' : '#909090'" dot />
|
||||
|
||||
<!-- 状态文本 -->
|
||||
@@ -454,7 +454,6 @@ import {
|
||||
CallTypeEnum,
|
||||
MittEnum,
|
||||
NotificationTypeEnum,
|
||||
OnlineEnum,
|
||||
RoleEnum,
|
||||
RoomActEnum,
|
||||
RoomTypeEnum,
|
||||
@@ -463,6 +462,7 @@ import {
|
||||
} from '@/enums'
|
||||
import { useAvatarUpload } from '@/hooks/useAvatarUpload'
|
||||
import { useMitt } from '@/hooks/useMitt.ts'
|
||||
import { useOnlineStatus } from '@/hooks/useOnlineStatus'
|
||||
import { useWindow } from '@/hooks/useWindow'
|
||||
import { IsAllUserEnum, type UserItem } from '@/services/types.ts'
|
||||
import { WsResponseMessageType } from '@/services/wsType'
|
||||
@@ -473,7 +473,6 @@ import { useGlobalStore } from '@/stores/global'
|
||||
import { useGroupStore } from '@/stores/group.ts'
|
||||
import { useSettingStore } from '@/stores/setting'
|
||||
import { useUserStore } from '@/stores/user.ts'
|
||||
import { useUserStatusStore } from '@/stores/userStatus'
|
||||
import { AvatarUtils } from '@/utils/AvatarUtils'
|
||||
import { notification, setSessionTop, shield, updateMyRoomInfo, updateRoomInfo } from '@/utils/ImRequestUtils'
|
||||
import { isMac, isWindows } from '@/utils/PlatformConstants'
|
||||
@@ -485,7 +484,6 @@ const chatStore = useChatStore()
|
||||
const groupStore = useGroupStore()
|
||||
const globalStore = useGlobalStore()
|
||||
const contactStore = useContactStore()
|
||||
const userStatusStore = useUserStatusStore()
|
||||
const userStore = useUserStore()
|
||||
const settingStore = useSettingStore()
|
||||
const { themes } = storeToRefs(settingStore)
|
||||
@@ -567,13 +565,13 @@ const messageSettingOptions = ref([
|
||||
{ label: '接收消息但不提醒', value: 'notification' },
|
||||
{ label: '屏蔽消息', value: 'shield' }
|
||||
])
|
||||
/** 是否在线 */
|
||||
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 chatTargetUid = computed(() => {
|
||||
if (!activeItem.value || activeItem.value.type === RoomTypeEnum.GROUP) return undefined
|
||||
return activeItem.value.detailId
|
||||
})
|
||||
const { isOnline, statusIcon, statusTitle, hasCustomState } = useOnlineStatus(chatTargetUid)
|
||||
|
||||
/** 是否还是好友 */
|
||||
const shouldShowDeleteFriend = computed(() => {
|
||||
if (!activeItem.value || activeItem.value.type === RoomTypeEnum.GROUP) return false
|
||||
@@ -596,28 +594,6 @@ const userList = computed(() => {
|
||||
})
|
||||
.slice(0, 10)
|
||||
})
|
||||
/** 获取当前用户的状态信息 */
|
||||
const currentUserStatus = computed(() => {
|
||||
if (!activeItem.value || activeItem.value.type === RoomTypeEnum.GROUP) return null
|
||||
|
||||
// 使用 useUserInfo 获取用户信息
|
||||
if (!activeItem.value.detailId) return null
|
||||
const userInfo = groupStore.getUserInfo(activeItem.value.detailId)!
|
||||
|
||||
// 从状态列表中找到对应的状态
|
||||
return userStatusStore.stateList.find((state: { id: string }) => state.id === userInfo.userStateId)
|
||||
})
|
||||
|
||||
/** 状态图标 */
|
||||
const statusIcon = computed(() => currentUserStatus.value?.url)
|
||||
|
||||
/** 状态标题 */
|
||||
const statusTitle = computed(() => {
|
||||
if (currentUserStatus.value?.title) {
|
||||
return currentUserStatus.value.title
|
||||
}
|
||||
return isOnline.value ? '在线' : '离线'
|
||||
})
|
||||
|
||||
// 获取用户的最新头像
|
||||
const currentUserAvatar = computed(() => {
|
||||
@@ -885,7 +861,7 @@ const handleDelete = (label: RoomActEnum) => {
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (optionsType.value === RoomActEnum.DELETE_FRIEND && activeItem.value.detailId) {
|
||||
contactStore.onDeleteContact(activeItem.value.detailId).then(() => {
|
||||
contactStore.onDeleteFriend(activeItem.value.detailId).then(() => {
|
||||
modalShow.value = false
|
||||
sidebarShow.value = false
|
||||
window.$message.success('已删除好友')
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
</n-flex>
|
||||
<!-- 气泡样式 -->
|
||||
<ContextMenu
|
||||
v-on-long-press="[(e) => handleLongPress(e, handleItemType(message.message.type)), longPressOption]"
|
||||
:content="message"
|
||||
@contextmenu="handleMacSelect"
|
||||
@mouseenter="() => (hoverMsgId = message.message.id)"
|
||||
@@ -293,6 +294,7 @@ import Video from './Video.vue'
|
||||
import VideoCall from './VideoCall.vue'
|
||||
import Voice from './Voice.vue'
|
||||
import { toFriendInfoPage } from '@/utils/routerUtils'
|
||||
import { vOnLongPress } from '@vueuse/components'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -505,6 +507,63 @@ onMounted(() => {
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('click', closeMenu, true)
|
||||
})
|
||||
|
||||
/**
|
||||
* 长按事件(开始)
|
||||
*/
|
||||
|
||||
const longPressOption = ref({
|
||||
delay: 700,
|
||||
modifiers: {
|
||||
prevent: true,
|
||||
stop: true
|
||||
},
|
||||
reset: true,
|
||||
windowResize: true,
|
||||
windowScroll: true,
|
||||
immediate: true,
|
||||
updateTiming: 'sync'
|
||||
})
|
||||
|
||||
const handleLongPress = (e: PointerEvent, _menu: any) => {
|
||||
// 1. 阻止默认行为(防止系统菜单出现)
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// // 2. 获取目标元素
|
||||
const target = e.target as HTMLElement
|
||||
|
||||
const preventClick = (event: Event) => {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
document.removeEventListener('click', preventClick, true)
|
||||
document.removeEventListener('pointerup', preventClick, true)
|
||||
}
|
||||
|
||||
// 3. 添加临时事件监听器,阻止后续点击事件
|
||||
document.addEventListener('click', preventClick, true)
|
||||
document.addEventListener('pointerup', preventClick, true)
|
||||
|
||||
// 4. 模拟右键点击事件
|
||||
const contextMenuEvent = new MouseEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
button: 2 // 明确指定右键
|
||||
})
|
||||
|
||||
target.dispatchEvent(contextMenuEvent)
|
||||
|
||||
setTimeout(() => {
|
||||
document.removeEventListener('click', preventClick, true)
|
||||
document.removeEventListener('pointerup', preventClick, true)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
/**
|
||||
* 长按事件(结束)
|
||||
*/
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/scss/render-message';
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
重新编辑
|
||||
</p>
|
||||
</n-flex>
|
||||
<span v-else class="text-12px color-#909090 select-none" v-html="body"></span>
|
||||
<span v-else class="text-12px color-#909090 select-none" v-html="recallText"></span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<n-flex align="center" :size="6">
|
||||
@@ -36,7 +36,7 @@ import type { MessageBody, MsgType } from '@/services/types'
|
||||
import { useChatStore } from '@/stores/chat.ts'
|
||||
import { useUserStore } from '@/stores/user.ts'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
message: MsgType
|
||||
fromUserUid: string
|
||||
isGroup?: boolean
|
||||
@@ -48,6 +48,16 @@ const userStore = useUserStore()
|
||||
|
||||
const userUid = computed(() => userStore.userInfo!.uid)
|
||||
|
||||
const recallText = computed(() => {
|
||||
// 处理body可能是字符串或对象的情况
|
||||
if (typeof props.body === 'string') {
|
||||
return props.body
|
||||
} else if (props.body && typeof props.body === 'object' && 'content' in props.body) {
|
||||
return props.body.content
|
||||
}
|
||||
return '撤回了一条消息'
|
||||
})
|
||||
|
||||
const canReEdit = computed(() => (msgId: string) => {
|
||||
const recalledMsg = chatStore.getRecalledMessage(msgId)
|
||||
const message = chatStore.getMessage(msgId)
|
||||
|
||||
@@ -26,7 +26,7 @@ import { extractFileName, removeTag } from '@/utils/Formatting'
|
||||
import { detectImageFormat, imageUrlToUint8Array, isImageUrl } from '@/utils/ImageUtils'
|
||||
import { recallMsg, removeGroupMember, updateMyRoomInfo } from '@/utils/ImRequestUtils'
|
||||
import { detectRemoteFileType, getFilesMeta } from '@/utils/PathUtil'
|
||||
import { isMac } from '@/utils/PlatformConstants'
|
||||
import { isMac, isMobile } from '@/utils/PlatformConstants'
|
||||
import { useWindow } from './useWindow'
|
||||
|
||||
type UseChatMainOptions = {
|
||||
@@ -185,6 +185,10 @@ export const useChatMain = (isHistoryMode = false, options: UseChatMainOptions =
|
||||
label: '转发',
|
||||
icon: 'share',
|
||||
click: (item: MessageType) => {
|
||||
if (isMobile()) {
|
||||
window.$message.warning('功能暂开发')
|
||||
return
|
||||
}
|
||||
handleForward(item)
|
||||
},
|
||||
visible: (item: MessageType) => !isNoticeMessage(item)
|
||||
@@ -224,10 +228,31 @@ export const useChatMain = (isHistoryMode = false, options: UseChatMainOptions =
|
||||
})
|
||||
},
|
||||
visible: (item: MessageType) => {
|
||||
if (isDiffNow({ time: item.message.sendTime, unit: 'minute', diff: 2 })) return
|
||||
const isSystemAdmin = userStore.userInfo?.power === PowerEnum.ADMIN
|
||||
if (isSystemAdmin) {
|
||||
return true
|
||||
}
|
||||
|
||||
const isGroupSession = globalStore.currentSession?.type === RoomTypeEnum.GROUP
|
||||
const groupMembers = groupStore.userList
|
||||
const currentMember = isGroupSession ? groupMembers.find((member) => member.uid === userUid.value) : undefined
|
||||
const isGroupManager =
|
||||
isGroupSession &&
|
||||
(currentMember?.roleId === RoleEnum.LORD ||
|
||||
currentMember?.roleId === RoleEnum.ADMIN ||
|
||||
groupStore.currentLordId === userUid.value ||
|
||||
groupStore.adminUidList.includes(userUid.value))
|
||||
|
||||
if (isGroupManager) {
|
||||
return true
|
||||
}
|
||||
|
||||
const isCurrentUser = item.fromUser.uid === userUid.value
|
||||
const isAdmin = userStore.userInfo!.power === PowerEnum.ADMIN
|
||||
return isCurrentUser || isAdmin
|
||||
if (!isCurrentUser) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !isDiffNow({ time: item.message.sendTime, unit: 'minute', diff: 2 })
|
||||
}
|
||||
}
|
||||
])
|
||||
@@ -236,6 +261,10 @@ export const useChatMain = (isHistoryMode = false, options: UseChatMainOptions =
|
||||
label: '复制',
|
||||
icon: 'copy',
|
||||
click: (item: MessageType) => {
|
||||
if (isMobile()) {
|
||||
window.$message.warning('功能暂开发')
|
||||
return
|
||||
}
|
||||
handleCopy(item.message.body.url, true)
|
||||
}
|
||||
},
|
||||
@@ -244,6 +273,10 @@ export const useChatMain = (isHistoryMode = false, options: UseChatMainOptions =
|
||||
label: '另存为',
|
||||
icon: 'Importing',
|
||||
click: async (item: MessageType) => {
|
||||
if (isMobile()) {
|
||||
window.$message.warning('功能暂开发')
|
||||
return
|
||||
}
|
||||
await saveVideoAttachmentAs({
|
||||
url: item.message.body.url,
|
||||
downloadFile,
|
||||
@@ -364,6 +397,10 @@ export const useChatMain = (isHistoryMode = false, options: UseChatMainOptions =
|
||||
label: '另存为',
|
||||
icon: 'Importing',
|
||||
click: async (item: MessageType) => {
|
||||
if (isMobile()) {
|
||||
window.$message.warning('功能暂开发')
|
||||
return
|
||||
}
|
||||
const fileUrl = item.message.body.url
|
||||
const fileName = item.message.body.fileName
|
||||
if (item.message.type === MsgEnum.VIDEO) {
|
||||
@@ -567,6 +604,10 @@ export const useChatMain = (isHistoryMode = false, options: UseChatMainOptions =
|
||||
label: '另存为',
|
||||
icon: 'Importing',
|
||||
click: async (item: RightMouseMessageItem) => {
|
||||
if (isMobile()) {
|
||||
window.$message.warning('功能暂开发')
|
||||
return
|
||||
}
|
||||
await saveFileAttachmentAs({
|
||||
url: item.message.body.url,
|
||||
downloadFile,
|
||||
@@ -640,6 +681,10 @@ export const useChatMain = (isHistoryMode = false, options: UseChatMainOptions =
|
||||
label: '另存为',
|
||||
icon: 'Importing',
|
||||
click: async (item: MessageType) => {
|
||||
if (isMobile()) {
|
||||
window.$message.warning('功能暂开发')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const imageUrl = item.message.body.url
|
||||
const suggestedName = imageUrl || 'image.png'
|
||||
|
||||
@@ -242,7 +242,7 @@ export const useMessage = () => {
|
||||
console.log('删除好友或退出群聊执行')
|
||||
// 单聊:删除好友
|
||||
if (item.type === RoomTypeEnum.SINGLE) {
|
||||
await contactStore.onDeleteContact(item.detailId)
|
||||
await contactStore.onDeleteFriend(item.detailId)
|
||||
await handleMsgDelete(item.roomId)
|
||||
window.$message.success('已删除好友')
|
||||
return
|
||||
|
||||
75
src/hooks/useOnlineStatus.ts
Normal file
75
src/hooks/useOnlineStatus.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { OnlineEnum } from '@/enums'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useUserStatusStore } from '@/stores/userStatus'
|
||||
|
||||
// 在线状态管理(仅是在线和离线)
|
||||
export const useOnlineStatus = (uid?: ComputedRef<string | undefined> | Ref<string | undefined>) => {
|
||||
const userStore = useUserStore()
|
||||
const groupStore = useGroupStore()
|
||||
const userStatusStore = useUserStatusStore()
|
||||
const { currentState } = storeToRefs(userStatusStore)
|
||||
|
||||
// 如果传入了uid参数,使用传入的uid对应的用户信息;否则使用当前登录用户的信息
|
||||
const currentUser = uid
|
||||
? computed(() => (uid.value ? groupStore.getUserInfo(uid.value) : undefined))
|
||||
: computed(() => {
|
||||
// 没有传入uid时,从groupStore获取当前用户信息以获得activeStatus
|
||||
const currentUid = userStore.userInfo?.uid
|
||||
return currentUid ? groupStore.getUserInfo(currentUid) : undefined
|
||||
})
|
||||
|
||||
// userStateId优先从userStore获取(保证响应式更新),如果没有则从currentUser获取
|
||||
const userStateId = uid
|
||||
? computed(() => currentUser.value?.userStateId)
|
||||
: computed(() => userStore.userInfo?.userStateId)
|
||||
|
||||
const activeStatus = computed(() => currentUser.value?.activeStatus ?? OnlineEnum.OFFLINE)
|
||||
|
||||
const hasCustomState = computed(() => {
|
||||
const stateId = userStateId.value
|
||||
// 只有 '0' 表示清空状态(无自定义状态),其他都是自定义状态
|
||||
return !!stateId && stateId !== '0'
|
||||
})
|
||||
|
||||
// 获取用户的状态信息
|
||||
const userStatus = computed(() => {
|
||||
if (!userStateId.value) return null
|
||||
return userStatusStore.stateList.find((state: { id: string }) => state.id === userStateId.value)
|
||||
})
|
||||
|
||||
const isOnline = computed(() => activeStatus.value === OnlineEnum.ONLINE)
|
||||
|
||||
const statusIcon = computed(() => {
|
||||
if (hasCustomState.value && userStatus.value?.url) {
|
||||
return userStatus.value.url
|
||||
}
|
||||
return isOnline.value ? '/status/online.png' : '/status/offline.png'
|
||||
})
|
||||
|
||||
const statusTitle = computed(() => {
|
||||
if (hasCustomState.value && userStatus.value?.title) {
|
||||
return userStatus.value.title
|
||||
}
|
||||
return isOnline.value ? '在线' : '离线'
|
||||
})
|
||||
|
||||
const statusBgColor = computed(() => {
|
||||
if (hasCustomState.value && userStatus.value?.bgColor) {
|
||||
return userStatus.value.bgColor
|
||||
}
|
||||
return isOnline.value ? 'rgba(26, 178, 146, 0.4)' : 'rgba(144, 144, 144, 0.4)'
|
||||
})
|
||||
|
||||
return {
|
||||
currentState,
|
||||
activeStatus,
|
||||
statusIcon,
|
||||
statusTitle,
|
||||
statusBgColor,
|
||||
isOnline,
|
||||
hasCustomState,
|
||||
userStatus
|
||||
}
|
||||
}
|
||||
@@ -503,12 +503,12 @@ listen('relogin', async () => {
|
||||
})
|
||||
|
||||
onBeforeMount(async () => {
|
||||
// 获取最新的未读数
|
||||
await contactStore.getApplyUnReadCount()
|
||||
// 刷新好友申请列表
|
||||
await contactStore.getApplyPage(true)
|
||||
// 刷新好友列表
|
||||
await contactStore.getContactList(true)
|
||||
// 获取最新的未读数
|
||||
await contactStore.getApplyUnReadCount()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -18,14 +18,14 @@
|
||||
<div
|
||||
class="bg-[--left-bg-color] text-10px rounded-50% size-12px absolute bottom--2px right--2px border-(2px solid [--left-bg-color])"
|
||||
@click.stop="openContent('在线状态', 'onlineStatus', 320, 480)">
|
||||
<img :src="currentState?.url" alt="" class="rounded-50% size-full" />
|
||||
<img :src="statusIcon" alt="" class="rounded-50% size-full" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 用户个人信息框 -->
|
||||
<n-flex
|
||||
:size="26"
|
||||
:style="`background: linear-gradient(to bottom, ${currentState?.bgColor} 0%, ${themeColor} 100%)`"
|
||||
:style="`background: linear-gradient(to bottom, ${statusBgColor} 0%, ${themeColor} 100%)`"
|
||||
class="size-full p-15px box-border rounded-8px"
|
||||
vertical>
|
||||
<!-- 头像以及信息区域 -->
|
||||
@@ -46,8 +46,8 @@
|
||||
align="center"
|
||||
class="item-hover ml--4px"
|
||||
@click="openContent('在线状态', 'onlineStatus', 320, 480)">
|
||||
<img :src="currentState?.url" alt="" class="rounded-50% size-18px" />
|
||||
<span>{{ currentState?.title }}</span>
|
||||
<img :src="statusIcon" alt="" class="rounded-50% size-18px" />
|
||||
<span>{{ statusTitle }}</span>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
@@ -85,17 +85,20 @@
|
||||
</n-popover>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { ThemeEnum } from '@/enums'
|
||||
import { useSettingStore } from '@/stores/setting'
|
||||
import { useUserStore } from '@/stores/user.ts'
|
||||
import { AvatarUtils } from '@/utils/AvatarUtils.ts'
|
||||
import { useOnlineStatus } from '@/hooks/useOnlineStatus.ts'
|
||||
import { leftHook } from '../hook.ts'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const settingStore = useSettingStore()
|
||||
const { themes } = storeToRefs(settingStore)
|
||||
const avatarSrc = computed(() => AvatarUtils.getAvatarUrl(userStore.userInfo!.avatar as string))
|
||||
const { shrinkStatus, currentState, infoShow, themeColor, openContent, handleEditing } = leftHook()
|
||||
const { shrinkStatus, infoShow, themeColor, openContent, handleEditing } = leftHook()
|
||||
const { statusIcon, statusTitle, statusBgColor } = useOnlineStatus()
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@use '../style';
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
<div class="h-140px relative w-full p-6px box-border">
|
||||
<img
|
||||
class="absolute blur-6px rounded-t-6px z-1 top-0 left-0 w-full h-140px object-cover"
|
||||
src="@/assets/img/dispersion-bg.png"
|
||||
src="/img/dispersion-bg.png"
|
||||
alt="" />
|
||||
<img
|
||||
class="absolute rounded-t-6px z-2 top-0 left-0 w-full h-140px object-cover"
|
||||
src="@/assets/img/dispersion-bg.png"
|
||||
src="/img/dispersion-bg.png"
|
||||
alt="" />
|
||||
|
||||
<div
|
||||
|
||||
@@ -71,5 +71,7 @@ async function setup() {
|
||||
await import('@/services/webSocketAdapter')
|
||||
await invoke('set_complete', { task: 'frontend' })
|
||||
hideInitialSplash()
|
||||
// 隐藏原生启动画面(Android/iOS)
|
||||
await invoke('hide_splash_screen')
|
||||
router.push('/mobile/login')
|
||||
}
|
||||
|
||||
@@ -283,7 +283,7 @@ const handleFriendAction = async (action: string, applyId: string) => {
|
||||
|
||||
onMounted(() => {
|
||||
// 组件挂载时刷新一次列表
|
||||
contactStore.getApplyPage(true)
|
||||
contactStore.getApplyPage(true, true)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -110,6 +110,7 @@
|
||||
<n-button
|
||||
:disabled="loading"
|
||||
v-if="!props.isMyPage && !isMyFriend"
|
||||
@click="handleAddFriend"
|
||||
class="px-4 py-10px font-bold text-center bg-#13987f text-white rounded-full text-12px">
|
||||
+ 添加好友
|
||||
</n-button>
|
||||
@@ -169,9 +170,7 @@ const globalStore = useGlobalStore()
|
||||
const chatStore = useChatStore()
|
||||
|
||||
const { preloadChatRoom } = useMessage()
|
||||
|
||||
const uid = route.params.uid as string
|
||||
|
||||
const isMyFriend = ref(props.isMyFriend)
|
||||
|
||||
const toChatRoom = async () => {
|
||||
@@ -190,18 +189,13 @@ const toChatRoom = async () => {
|
||||
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)
|
||||
// }
|
||||
// }
|
||||
const handleAddFriend = async () => {
|
||||
globalStore.addFriendModalInfo.uid = uid
|
||||
router.push('/mobile/mobileFriends/confirmAddFriend')
|
||||
}
|
||||
|
||||
// 用户详情信息,默认字段只写必要的,不加可能会报错undefined
|
||||
const userDetailInfo = ref<UserItem | UserInfoType | undefined>({
|
||||
@@ -285,8 +279,11 @@ const handleDelete = () => {
|
||||
if (userDetailInfo.value?.uid) {
|
||||
try {
|
||||
loading.value = true
|
||||
await contactStore.onDeleteContact(userDetailInfo.value.uid)
|
||||
await contactStore.onDeleteFriend(userDetailInfo.value.uid)
|
||||
isMyFriend.value = false
|
||||
chatStore.getSessionList(true)
|
||||
window.$message.success('已删除好友')
|
||||
router.back()
|
||||
} catch (error) {
|
||||
window.$message.warning('删除失败')
|
||||
console.error('删除好友失败:', error)
|
||||
|
||||
@@ -9,8 +9,13 @@
|
||||
<div
|
||||
v-if="showMask"
|
||||
@touchend="maskHandler.close"
|
||||
@click="maskHandler.close"
|
||||
class="fixed inset-0 bg-black/20 backdrop-blur-sm z-[999] transition-all duration-3000 ease-in-out opacity-100"></div>
|
||||
@mouseup="maskHandler.close"
|
||||
:class="[
|
||||
longPressState.longPressActive
|
||||
? ''
|
||||
: 'bg-black/20 backdrop-blur-sm transition-all duration-3000 ease-in-out opacity-100'
|
||||
]"
|
||||
class="fixed inset-0 z-[999]"></div>
|
||||
|
||||
<!-- 键盘蒙板 -->
|
||||
<div
|
||||
@@ -78,21 +83,31 @@
|
||||
@focus="lockScroll"
|
||||
@blur="unlockScroll">
|
||||
<template #prefix>
|
||||
<svg class="w-12px h-12px"><use href="#search"></use></svg>
|
||||
<svg class="w-12px h-12px">
|
||||
<use href="#search"></use>
|
||||
</svg>
|
||||
</template>
|
||||
</n-input>
|
||||
</div>
|
||||
<div class="border-b-1 border-solid color-gray-200 px-18px mt-5px"></div>
|
||||
</div>
|
||||
|
||||
<van-pull-refresh class="h-full" :disabled="!atTop" v-model="loading" @refresh="onRefresh">
|
||||
<van-pull-refresh
|
||||
class="h-full"
|
||||
:pull-distance="100"
|
||||
:disabled="!isEnablePullRefresh"
|
||||
v-model="loading"
|
||||
@refresh="onRefresh">
|
||||
<div class="flex flex-col h-full px-18px">
|
||||
<div class="flex-1 overflow-auto" @scroll="onScroll" ref="scrollContainer">
|
||||
<div class="flex-1 overflow-auto" @scroll.prevent="onScroll" ref="scrollContainer">
|
||||
<van-swipe-cell
|
||||
@open="handleSwipeOpen"
|
||||
@close="handleSwipeClose"
|
||||
v-for="(item, idx) in sessionList"
|
||||
:key="`${item.id}-${idx}`">
|
||||
v-on-long-press="[(e: PointerEvent) => handleLongPress(e, item), longPressOption]"
|
||||
:key="`${item.id}-${idx}`"
|
||||
:class="item.top ? 'bg-gray-200' : ''">
|
||||
<!-- 长按项 -->
|
||||
<div @click="intoRoom(item)" class="grid grid-cols-[2.2rem_1fr_4rem] items-start px-2 py-3 gap-1">
|
||||
<div class="flex-shrink-0">
|
||||
<n-badge :offset="[-6, 6]" :value="item.unreadCount" :max="99">
|
||||
@@ -121,19 +136,54 @@
|
||||
</div>
|
||||
<template #right>
|
||||
<div class="flex w-auto flex-wrap h-full">
|
||||
<div class="h-full text-14px w-80px bg-#13987f text-white flex items-center justify-center">置顶</div>
|
||||
<div class="h-full text-14px w-80px bg-red text-white flex items-center justify-center">删除</div>
|
||||
<div
|
||||
class="h-full text-14px w-80px bg-#13987f text-white flex items-center justify-center"
|
||||
@click="handleToggleTop(item)">
|
||||
{{ item.top ? '取消置顶' : '置顶' }}
|
||||
</div>
|
||||
<div
|
||||
class="h-full text-14px w-80px bg-red text-white flex items-center justify-center"
|
||||
@click="handleDelete(item)">
|
||||
删除
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</van-swipe-cell>
|
||||
</div>
|
||||
</div>
|
||||
</van-pull-refresh>
|
||||
|
||||
<teleport to="body">
|
||||
<div
|
||||
v-if="longPressState.showLongPressMenu"
|
||||
:style="{ top: longPressState.longPressMenuTop + 'px' }"
|
||||
class="fixed gap-10px z-999 left-1/2 transform -translate-x-1/2">
|
||||
<div class="flex justify-between p-[8px_15px_8px_15px] text-14px gap-10px rounded-10px bg-#4e4e4e">
|
||||
<div class="text-white" @click="handleDelete(currentLongPressItem)">删除</div>
|
||||
<div class="text-white" @click="handleToggleTop(currentLongPressItem)">
|
||||
{{ currentLongPressItem?.top ? '取消置顶' : '置顶' }}
|
||||
</div>
|
||||
<div class="text-white" @click="handleMarkUnread">未读</div>
|
||||
</div>
|
||||
<div class="flex w-full justify-center h-15px">
|
||||
<svg width="35" height="13" viewBox="0 0 35 13">
|
||||
<path
|
||||
d="M0 0
|
||||
Q17.5 5 17.5 12
|
||||
Q17.5 13 18.5 13
|
||||
Q17.5 13 17.5 12
|
||||
Q17.5 5 35 0
|
||||
Z"
|
||||
fill="#4e4e4e" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { debounce } from 'lodash-es'
|
||||
import { debounce, throttle } from 'lodash-es'
|
||||
import SafeAreaPlaceholder from '#/components/placeholders/SafeAreaPlaceholder.vue'
|
||||
import NavBar from '#/layout/navBar/index.vue'
|
||||
import type { IKeyboardDidShowDetail } from '#/mobile-client/interface/adapter'
|
||||
@@ -143,7 +193,7 @@ import groupChatIcon from '@/assets/mobile/chat-home/group-chat.webp'
|
||||
import { RoomTypeEnum } from '@/enums'
|
||||
import { useMessage } from '@/hooks/useMessage.ts'
|
||||
import { useReplaceMsg } from '@/hooks/useReplaceMsg'
|
||||
import { IsAllUserEnum } from '@/services/types.ts'
|
||||
import { IsAllUserEnum, type SessionItem } from '@/services/types.ts'
|
||||
import rustWebSocketClient from '@/services/webSocketRust'
|
||||
import { useChatStore } from '@/stores/chat.ts'
|
||||
import { useGlobalStore } from '@/stores/global'
|
||||
@@ -151,15 +201,63 @@ import { useGroupStore } from '@/stores/group'
|
||||
import { useUserStore } from '@/stores/user.ts'
|
||||
import { AvatarUtils } from '@/utils/AvatarUtils'
|
||||
import { formatTimestamp } from '@/utils/ComputedTime.ts'
|
||||
import { vOnLongPress } from '@vueuse/components'
|
||||
import { setSessionTop } from '@/utils/ImRequestUtils'
|
||||
|
||||
const loading = ref(false)
|
||||
const count = ref(0)
|
||||
|
||||
const currentLongPressItem = ref<SessionItem | null>(null)
|
||||
const groupStore = useGroupStore()
|
||||
const chatStore = useChatStore()
|
||||
const userStore = useUserStore()
|
||||
const globalStore = useGlobalStore()
|
||||
|
||||
// 加载更多ui事件处理(开始)
|
||||
|
||||
const isEnablePullRefresh = ref(true) // 是否启用下拉刷新,现在设置为滚动到顶才启用
|
||||
const scrollContainer = ref(null) // 消息滚动容器
|
||||
|
||||
let scrollTop = 0 // 记住当前滑动到哪了
|
||||
|
||||
const enablePullRefresh = debounce((top: number) => {
|
||||
isEnablePullRefresh.value = top === 0
|
||||
}, 100)
|
||||
|
||||
const disablePullRefresh = throttle(() => {
|
||||
isEnablePullRefresh.value = false
|
||||
}, 200)
|
||||
|
||||
const onScroll = (e: any) => {
|
||||
scrollTop = e.target.scrollTop
|
||||
if (scrollTop < 100) {
|
||||
enablePullRefresh(scrollTop)
|
||||
} else {
|
||||
disablePullRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
// 加载更多ui事件处理(结束)
|
||||
|
||||
const longPressState = ref({
|
||||
showLongPressMenu: false,
|
||||
longPressMenuTop: 0,
|
||||
longPressActive: false,
|
||||
// 禁用所有事件
|
||||
enable: () => {
|
||||
// 设置长按激活状态
|
||||
longPressState.value.longPressActive = true
|
||||
disablePullRefresh()
|
||||
},
|
||||
|
||||
disable: () => {
|
||||
longPressState.value.showLongPressMenu = false
|
||||
longPressState.value.longPressMenuTop = 0
|
||||
longPressState.value.longPressActive = false
|
||||
isEnablePullRefresh.value = true
|
||||
enablePullRefresh(scrollTop)
|
||||
}
|
||||
})
|
||||
|
||||
const allUserMap = computed(() => {
|
||||
const map = new Map<string, any>() // User 是你定义的用户类型
|
||||
groupStore.allUserInfo.forEach((user) => {
|
||||
@@ -168,7 +266,7 @@ const allUserMap = computed(() => {
|
||||
return map
|
||||
})
|
||||
|
||||
// 会话列表 TODO: 需要后端返回对应字段
|
||||
// 会话列表
|
||||
const sessionList = computed(() => {
|
||||
return (
|
||||
chatStore.sessionList
|
||||
@@ -228,6 +326,64 @@ const sessionList = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
// 删除会话
|
||||
const handleDelete = async (item: SessionItem | null) => {
|
||||
if (!item) return
|
||||
|
||||
try {
|
||||
await handleMsgDelete(item.roomId)
|
||||
} catch (error) {
|
||||
console.error('删除会话失败:', error)
|
||||
} finally {
|
||||
maskHandler.close()
|
||||
}
|
||||
}
|
||||
|
||||
// 置顶/取消置顶
|
||||
const handleToggleTop = async (item: SessionItem | null) => {
|
||||
if (!item) return
|
||||
|
||||
try {
|
||||
const newTopState = !item.top
|
||||
|
||||
await setSessionTop({
|
||||
roomId: item.roomId,
|
||||
top: newTopState
|
||||
})
|
||||
|
||||
// 更新本地会话状态
|
||||
chatStore.updateSession(item.roomId, { top: newTopState })
|
||||
} catch (error) {
|
||||
console.error('置顶操作失败:', error)
|
||||
} finally {
|
||||
maskHandler.close()
|
||||
}
|
||||
}
|
||||
|
||||
// 标记未读
|
||||
const handleMarkUnread = async () => {
|
||||
if (!currentLongPressItem.value) return
|
||||
|
||||
try {
|
||||
const item = currentLongPressItem.value
|
||||
|
||||
// 重置未读计数为1
|
||||
chatStore.updateSession(item.roomId, {
|
||||
unreadCount: 1
|
||||
})
|
||||
|
||||
// 更新全局未读计数
|
||||
globalStore.updateGlobalUnreadCount()
|
||||
|
||||
window.$message.success('已标记为未读')
|
||||
} catch (error) {
|
||||
window.$message.error('标记未读失败')
|
||||
console.error('标记未读失败:', error)
|
||||
} finally {
|
||||
maskHandler.close()
|
||||
}
|
||||
}
|
||||
|
||||
const onRefresh = () => {
|
||||
// 如果没到0.5秒就延迟0.5秒,如果接口执行时间超过0.5秒那就以getSessionList时间为准
|
||||
loading.value = true
|
||||
@@ -271,13 +427,6 @@ onMounted(async () => {
|
||||
})
|
||||
})
|
||||
|
||||
const atTop = ref(true) // 是否滚动到顶
|
||||
const scrollContainer = ref(null) //消息滚动容器
|
||||
|
||||
const onScroll = debounce((e: any) => {
|
||||
atTop.value = e.target.scrollTop === 0
|
||||
}, 500)
|
||||
|
||||
/**
|
||||
* 渲染图片图标的函数工厂
|
||||
* @param {string} src - 图标图片路径
|
||||
@@ -310,6 +459,8 @@ const uiViewsData = ref({
|
||||
]
|
||||
})
|
||||
|
||||
// 页面蒙板相关处理(开始)
|
||||
|
||||
/**
|
||||
* 页面蒙板显示状态
|
||||
* @type {import('vue').Ref<boolean>}
|
||||
@@ -342,17 +493,23 @@ const maskHandler = {
|
||||
* 关闭蒙板,恢复滚动状态和位置
|
||||
*/
|
||||
close: () => {
|
||||
setTimeout(() => {
|
||||
const closeModal = () => {
|
||||
showMask.value = false
|
||||
document.body.style.overflow = ''
|
||||
document.body.style.position = ''
|
||||
document.body.style.top = ''
|
||||
document.body.style.width = ''
|
||||
window.scrollTo(0, scrollY) // 恢复滚动位置
|
||||
}, 200)
|
||||
}
|
||||
|
||||
setTimeout(closeModal, 60)
|
||||
|
||||
longPressState.value.disable()
|
||||
}
|
||||
}
|
||||
|
||||
// 页面蒙板相关处理(结束)
|
||||
|
||||
/**
|
||||
* 添加按钮相关事件处理对象
|
||||
*/
|
||||
@@ -382,8 +539,7 @@ const addIconHandler = {
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const { handleMsgClick } = useMessage()
|
||||
const { handleMsgClick, handleMsgDelete } = useMessage()
|
||||
|
||||
// 阻止消息的点击事件,为false时不阻止
|
||||
let preventClick = false
|
||||
@@ -397,6 +553,10 @@ const handleSwipeClose = () => {
|
||||
}
|
||||
|
||||
const intoRoom = (item: any) => {
|
||||
if (longPressState.value.longPressActive) {
|
||||
return
|
||||
}
|
||||
|
||||
if (preventClick) {
|
||||
return
|
||||
}
|
||||
@@ -461,9 +621,83 @@ const closeKeyboardMask = () => {
|
||||
activeEl.blur()
|
||||
}
|
||||
}
|
||||
|
||||
// 长按事件处理(开始)
|
||||
|
||||
const longPressOption = ref({
|
||||
delay: 200,
|
||||
modifiers: {
|
||||
prevent: true,
|
||||
stop: true
|
||||
},
|
||||
reset: true,
|
||||
windowResize: true,
|
||||
windowScroll: true,
|
||||
immediate: true,
|
||||
updateTiming: 'sync'
|
||||
})
|
||||
|
||||
const handleLongPress = (e: PointerEvent, item: SessionItem) => {
|
||||
const latestItem = chatStore.sessionList.find((session) => session.roomId === item.roomId)
|
||||
if (!latestItem) return
|
||||
|
||||
currentLongPressItem.value = latestItem
|
||||
|
||||
e.stopPropagation()
|
||||
|
||||
maskHandler.open()
|
||||
|
||||
longPressState.value.enable()
|
||||
|
||||
// 设置长按菜单top值
|
||||
const setLongPressMenuTop = () => {
|
||||
const target = e.target as HTMLElement
|
||||
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentTarget = target.closest('.grid') // 向上找父级,找到grid就停止
|
||||
|
||||
if (!currentTarget) {
|
||||
return
|
||||
}
|
||||
|
||||
const rect = currentTarget.getBoundingClientRect()
|
||||
|
||||
longPressState.value.longPressMenuTop = rect.top - rect.height / 3
|
||||
}
|
||||
|
||||
setLongPressMenuTop()
|
||||
|
||||
longPressState.value.showLongPressMenu = true // 显示长按菜单
|
||||
}
|
||||
|
||||
// 长按事件处理(结束)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.van-swipe-cell {
|
||||
width: 100%;
|
||||
transition: background-color 0.3s ease;
|
||||
|
||||
/* 置顶时的背景色 - 新设计 */
|
||||
&.bg-gray-200 {
|
||||
background-color: #f0f7ff; /* 柔和的浅蓝色背景 */
|
||||
border-left: 4px solid #1890ff; /* 左侧蓝色标识条 */
|
||||
|
||||
/* 右侧操作按钮的背景色 */
|
||||
:deep() .van-swipe-cell__right {
|
||||
background-color: #f0f7ff;
|
||||
}
|
||||
|
||||
/* 悬停效果 */
|
||||
&:hover {
|
||||
background-color: #e1efff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.keyboard-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<n-flex data-tauri-drag-region vertical :size="10" align="center" justify="center" class="flex flex-1">
|
||||
<!-- logo -->
|
||||
<img data-tauri-drag-region class="w-275px h-125px drop-shadow-2xl" src="../../../assets/logo/hula.png" alt="" />
|
||||
<img data-tauri-drag-region class="w-275px h-125px drop-shadow-2xl" src="/hula.png" alt="" />
|
||||
|
||||
<n-flex data-tauri-drag-region vertical justify="center" :size="16" class="p-[30px_20px]">
|
||||
<p class="text-(14px [--chat-text-color])">你可以尝试使用以下功能:</p>
|
||||
|
||||
@@ -12,6 +12,7 @@ import ChatRoomLayout from '#/layout/chat-room/ChatRoomLayout.vue'
|
||||
import NoticeLayout from '#/layout/chat-room/NoticeLayout.vue'
|
||||
import FriendsLayout from '#/layout/friends/FriendsLayout.vue'
|
||||
import MobileHome from '#/layout/index.vue'
|
||||
import GroupChatMember from '#/views/chat-room/GroupChatMember.vue'
|
||||
import MyLayout from '#/layout/my/MyLayout.vue'
|
||||
import MobileLogin from '#/login.vue'
|
||||
import ChatSetting from '#/views/chat-room/ChatSetting.vue'
|
||||
@@ -78,13 +79,20 @@ const getMobileRoutes = (): Array<RouteRecordRaw> => [
|
||||
path: 'chatMain/:uid?', // 可选传入,如果传入uid就表示房间属于好友的私聊房间
|
||||
name: 'mobileChatMain',
|
||||
component: MobileChatMain,
|
||||
props: true
|
||||
props: true,
|
||||
meta: { keepAlive: true }
|
||||
},
|
||||
{
|
||||
path: 'setting',
|
||||
name: 'mobileChatSetting',
|
||||
component: ChatSetting
|
||||
},
|
||||
{
|
||||
path: 'groupChatMember',
|
||||
name: 'mobileGroupChatMember',
|
||||
component: GroupChatMember,
|
||||
meta: { keepAlive: true }
|
||||
},
|
||||
{
|
||||
path: 'notice',
|
||||
name: 'mobileChatNotice',
|
||||
|
||||
@@ -547,7 +547,28 @@ export const useChatStore = defineStore(
|
||||
const message = currentMessageMap.value!.get(msgId)
|
||||
if (message && typeof data.recallUid === 'string') {
|
||||
const cacheUser = groupStore.getUserInfo(data.recallUid)!
|
||||
const recallMessageBody = `"${cacheUser.name}"撤回了一条消息`
|
||||
let recallMessageBody: string
|
||||
|
||||
// 判断是否是撤回他人的消息(群主或管理员撤回成员消息)
|
||||
const isRecallOthers = message.fromUser.uid !== data.recallUid
|
||||
|
||||
if (isRecallOthers) {
|
||||
// 检查撤回人是否是群主或管理员
|
||||
const isLord = groupStore.isCurrentLord(data.recallUid)
|
||||
const isAdmin = groupStore.isAdmin(data.recallUid)
|
||||
|
||||
if (isLord) {
|
||||
recallMessageBody = `群主"${cacheUser.name}"撤回了一条成员消息`
|
||||
} else if (isAdmin) {
|
||||
recallMessageBody = `管理员"${cacheUser.name}"撤回了一条成员消息`
|
||||
} else {
|
||||
// 普通成员撤回他人消息(一般不应该出现)
|
||||
recallMessageBody = `"${cacheUser.name}"撤回了一条消息`
|
||||
}
|
||||
} else {
|
||||
// 撤回自己的消息
|
||||
recallMessageBody = `"${cacheUser.name}"撤回了一条消息`
|
||||
}
|
||||
|
||||
// 更新前端缓存
|
||||
message.message.type = MsgEnum.RECALL
|
||||
|
||||
@@ -65,8 +65,9 @@ export const useContactStore = defineStore(StoresEnum.CONTACTS, () => {
|
||||
/**
|
||||
* 获取好友申请列表
|
||||
* @param isFresh 是否刷新列表,true则重新加载,false则加载更多
|
||||
* @param click 是否点击刷新,true则点击清空通知未读,false则仅仅请求通知列表
|
||||
*/
|
||||
const getApplyPage = async (isFresh = false) => {
|
||||
const getApplyPage = async (isFresh = false, click = false) => {
|
||||
// 非刷新模式下,如果已经加载完或正在加载中,则直接返回
|
||||
if (!isFresh) {
|
||||
if (applyPageOptions.value.isLast) return
|
||||
@@ -82,6 +83,7 @@ export const useContactStore = defineStore(StoresEnum.CONTACTS, () => {
|
||||
const res = await requestNoticePage({
|
||||
pageNo: applyPageOptions.value.pageNo,
|
||||
pageSize: 30,
|
||||
click: click,
|
||||
cursor: isFresh ? '' : applyPageOptions.value.cursor
|
||||
})
|
||||
if (!res) return
|
||||
@@ -141,12 +143,10 @@ export const useContactStore = defineStore(StoresEnum.CONTACTS, () => {
|
||||
* 1. 调用删除好友接口
|
||||
* 2. 刷新好友列表
|
||||
*/
|
||||
const onDeleteContact = async (uid: string) => {
|
||||
const onDeleteFriend = async (uid: string) => {
|
||||
if (!uid) return
|
||||
// 删除好友
|
||||
await deleteFriend({ targetUid: uid })
|
||||
// 刷新好友申请列表
|
||||
// getRequestFriendsList(true)
|
||||
// 刷新好友列表
|
||||
await getContactList(true)
|
||||
}
|
||||
@@ -159,7 +159,7 @@ export const useContactStore = defineStore(StoresEnum.CONTACTS, () => {
|
||||
requestFriendsList,
|
||||
contactsOptions,
|
||||
applyPageOptions,
|
||||
onDeleteContact,
|
||||
onDeleteFriend,
|
||||
onHandleInvite,
|
||||
deleteContact
|
||||
}
|
||||
|
||||
10
src/typings/components.d.ts
vendored
10
src/typings/components.d.ts
vendored
@@ -52,10 +52,8 @@ declare module 'vue' {
|
||||
NaiveProvider: typeof import('./../components/common/NaiveProvider.vue')['default']
|
||||
NAutoComplete: typeof import('naive-ui')['NAutoComplete']
|
||||
NAvatar: typeof import('naive-ui')['NAvatar']
|
||||
NAvatarGroup: typeof import('naive-ui')['NAvatarGroup']
|
||||
NBadge: typeof import('naive-ui')['NBadge']
|
||||
NButton: typeof import('naive-ui')['NButton']
|
||||
NButtonGroup: typeof import('naive-ui')['NButtonGroup']
|
||||
NCheckbox: typeof import('naive-ui')['NCheckbox']
|
||||
NCheckboxGroup: typeof import('naive-ui')['NCheckboxGroup']
|
||||
NCollapse: typeof import('naive-ui')['NCollapse']
|
||||
@@ -73,7 +71,6 @@ declare module 'vue' {
|
||||
NFormItem: typeof import('naive-ui')['NFormItem']
|
||||
NHighlight: typeof import('naive-ui')['NHighlight']
|
||||
NIcon: typeof import('naive-ui')['NIcon']
|
||||
NIconWrapper: typeof import('naive-ui')['NIconWrapper']
|
||||
NImage: typeof import('naive-ui')['NImage']
|
||||
NImageGroup: typeof import('naive-ui')['NImageGroup']
|
||||
NInput: typeof import('naive-ui')['NInput']
|
||||
@@ -83,8 +80,6 @@ declare module 'vue' {
|
||||
NModalProvider: typeof import('naive-ui')['NModalProvider']
|
||||
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
|
||||
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']
|
||||
NResult: typeof import('naive-ui')['NResult']
|
||||
@@ -94,14 +89,9 @@ declare module 'vue' {
|
||||
NSpace: typeof import('naive-ui')['NSpace']
|
||||
NSpin: typeof import('naive-ui')['NSpin']
|
||||
NSwitch: typeof import('naive-ui')['NSwitch']
|
||||
NTab: typeof import('naive-ui')['NTab']
|
||||
NTabPane: typeof import('naive-ui')['NTabPane']
|
||||
NTabs: typeof import('naive-ui')['NTabs']
|
||||
NTag: typeof import('naive-ui')['NTag']
|
||||
NTimeline: typeof import('naive-ui')['NTimeline']
|
||||
NTimelineItem: typeof import('naive-ui')['NTimelineItem']
|
||||
NTooltip: typeof import('naive-ui')['NTooltip']
|
||||
NTransfer: typeof import('naive-ui')['NTransfer']
|
||||
NUpload: typeof import('naive-ui')['NUpload']
|
||||
NVirtualList: typeof import('naive-ui')['NVirtualList']
|
||||
PersonalInfo: typeof import('./../mobile/components/my/PersonalInfo.vue')['default']
|
||||
|
||||
@@ -322,7 +322,7 @@ export async function sendAddFriendRequest(body: { targetUid: string; msg: strin
|
||||
})
|
||||
}
|
||||
|
||||
export async function requestNoticePage(params: { pageSize: number; pageNo: number; cursor: string }) {
|
||||
export async function requestNoticePage(params: { pageSize: number; pageNo: number; cursor: string; click: boolean }) {
|
||||
return await imRequest({
|
||||
url: ImUrlEnum.REQUEST_NOTICE_PAGE,
|
||||
params
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
class="w-170px h-113px absolute top-9% left-51% transform -translate-x-51% -translate-y-9%"></div>
|
||||
<img
|
||||
class="drop-shadow-md absolute top-30% left-1/2 transform -translate-x-1/2 -translate-y-30% w-140px h-60px"
|
||||
src="../../assets/logo/hula.png"
|
||||
src="/hula.png"
|
||||
alt="" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -194,7 +194,7 @@ const handleSelect = (event: MouseEvent) => {
|
||||
|
||||
const handleApply = async (applyType: 'friend' | 'group') => {
|
||||
// 刷新好友申请列表
|
||||
await contactStore.getApplyPage(true)
|
||||
await contactStore.getApplyPage(true, true)
|
||||
|
||||
// 更新未读数
|
||||
if (applyType === 'friend') {
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
<!-- 自动登录样式 -->
|
||||
<n-flex v-else-if="uiState === 'auto'" vertical :size="29" data-tauri-drag-region>
|
||||
<n-flex justify="center" class="mt-15px">
|
||||
<img src="@/assets/logo/hula.png" class="w-140px h-60px" alt="" />
|
||||
<img src="/hula.png" class="w-140px h-60px" alt="" />
|
||||
</n-flex>
|
||||
<n-flex :size="30" vertical>
|
||||
<!-- 头像 -->
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<ActionBar :max-w="false" :shrink="false" proxy data-tauri-drag-region />
|
||||
|
||||
<n-flex justify="center" class="mt-15px" data-tauri-drag-region>
|
||||
<img src="@/assets/logo/hula.png" class="w-140px h-60px drop-shadow-xl" alt="" data-tauri-drag-region />
|
||||
<img src="/hula.png" class="w-140px h-60px drop-shadow-xl" alt="" data-tauri-drag-region />
|
||||
</n-flex>
|
||||
|
||||
<!-- 二维码 -->
|
||||
|
||||
@@ -10,17 +10,29 @@
|
||||
data-tauri-drag-region>
|
||||
<!-- 当前选中的状态 -->
|
||||
<n-flex justify="center" align="center" class="pt-80px" data-tauri-drag-region>
|
||||
<img class="w-34px h-34px" :src="currentState?.url" alt="" />
|
||||
<span class="text-22px">{{ currentState?.title }}</span>
|
||||
<img class="w-34px h-34px" :src="statusIcon" alt="" />
|
||||
<span class="text-22px">{{ statusTitle }}</span>
|
||||
</n-flex>
|
||||
|
||||
<!-- 状态 -->
|
||||
<n-flex vertical class="w-full h-100vh bg-#f1f1f1 rounded-6px box-border p-13px" data-tauri-drag-region>
|
||||
<n-scrollbar style="max-height: 215px">
|
||||
<n-flex align="center" :size="10">
|
||||
<n-flex
|
||||
@click="handleActive(resetState)"
|
||||
vertical
|
||||
justify="center"
|
||||
align="center"
|
||||
:size="8"
|
||||
class="status-item">
|
||||
<svg class="size-24px color-#d03553">
|
||||
<use href="#forbid"></use>
|
||||
</svg>
|
||||
<span class="text-11px">清空状态</span>
|
||||
</n-flex>
|
||||
<n-flex
|
||||
@click="handleActive(item)"
|
||||
:class="{ active: currentState?.id === item.id }"
|
||||
:class="{ active: hasCustomState && currentState?.id === item.id }"
|
||||
v-for="item in stateList"
|
||||
:key="item.title"
|
||||
vertical
|
||||
@@ -28,7 +40,7 @@
|
||||
align="center"
|
||||
:size="8"
|
||||
class="status-item">
|
||||
<img class="w-24px h-24px" :src="item.url" alt="" />
|
||||
<img class="size-24px" :src="item.url" alt="" />
|
||||
<span class="text-11px">{{ item.title }}</span>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
@@ -39,19 +51,27 @@
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { UserState } from '@/services/types'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useUserStatusStore } from '@/stores/userStatus'
|
||||
import { useOnlineStatus } from '@/hooks/useOnlineStatus.ts'
|
||||
import { changeUserState } from '@/utils/ImRequestUtils'
|
||||
|
||||
const userStatusStore = useUserStatusStore()
|
||||
const userStore = useUserStore()
|
||||
const { currentState, stateList, stateId } = storeToRefs(userStatusStore)
|
||||
const { stateList, stateId } = storeToRefs(userStatusStore)
|
||||
const { currentState, statusIcon, statusTitle, statusBgColor, hasCustomState } = useOnlineStatus()
|
||||
const resetState: UserState = {
|
||||
id: '0',
|
||||
title: '清空状态',
|
||||
url: ''
|
||||
}
|
||||
/** 这里不写入activeItem中是因为v-bind要绑定的值是响应式的 */
|
||||
const RGBA = ref(currentState.value?.bgColor)
|
||||
const RGBA = ref(statusBgColor.value)
|
||||
|
||||
watchEffect(() => {
|
||||
RGBA.value = currentState.value?.bgColor
|
||||
RGBA.value = statusBgColor.value
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -74,8 +94,9 @@ const handleActive = async (item: UserState) => {
|
||||
|
||||
onMounted(async () => {
|
||||
await getCurrentWebviewWindow().show()
|
||||
currentState.value.id =
|
||||
stateList.value.find((item: { title: string }) => item.title === currentState.value.title)?.id || '1'
|
||||
if (!currentState.value) return
|
||||
const matched = stateList.value.find((item: { title: string }) => item.title === currentState.value?.title)
|
||||
currentState.value.id = matched?.id || '1'
|
||||
})
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<n-flex vertical justify="center" :size="25" class="pt-70px w-full">
|
||||
<n-flex justify="center" align="center">
|
||||
<span class="text-(24px #70938c) textFont">欢迎注册</span>
|
||||
<img class="w-100px h-40px" src="@/assets/logo/hula.png" alt="" />
|
||||
<img class="w-100px h-40px" src="/hula.png" alt="" />
|
||||
</n-flex>
|
||||
|
||||
<!-- 注册菜单 -->
|
||||
|
||||
Reference in New Issue
Block a user