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:
Dawn
2025-10-14 01:36:28 +08:00
committed by GitHub
parent c5905d7c7b
commit 86f873633c
46 changed files with 906 additions and 144 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
View File

@@ -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
View File

@@ -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

View File

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 126 KiB

2
src-tauri/Cargo.lock generated
View File

@@ -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",

View File

@@ -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"

View File

@@ -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 ->

View 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

View File

@@ -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>

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -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()))?;

View File

@@ -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,
]
}

View File

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

View File

@@ -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 {

View File

@@ -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 = () => {

View File

@@ -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"

View File

@@ -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('已删除好友')

View File

@@ -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';

View File

@@ -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)

View File

@@ -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'

View File

@@ -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

View 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
}
}

View File

@@ -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 () => {

View File

@@ -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';

View File

@@ -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

View File

@@ -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')
}

View File

@@ -283,7 +283,7 @@ const handleFriendAction = async (action: string, applyId: string) => {
onMounted(() => {
// 组件挂载时刷新一次列表
contactStore.getApplyPage(true)
contactStore.getApplyPage(true, true)
})
</script>

View File

@@ -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">
+&nbsp;添加好友
</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)

View File

@@ -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;

View File

@@ -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>

View File

@@ -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',

View File

@@ -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

View File

@@ -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
}

View File

@@ -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']

View File

@@ -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

View File

@@ -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>

View File

@@ -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') {

View File

@@ -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>
<!-- 头像 -->

View File

@@ -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>
<!-- 二维码 -->

View File

@@ -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">

View File

@@ -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>
<!-- 注册菜单 -->