feat(im):增加好友删除时,增加是否删除本地聊天的选项

im
YunaiV 2026-05-05 00:33:06 +08:00
parent 1400bd80dd
commit 2a55748296
5 changed files with 157 additions and 53 deletions

View File

@ -38,8 +38,11 @@ export const getFriend = (friendUserId: number | string) => {
}
// 删除好友(单向软删除)
export const deleteFriend = (friendUserId: number | string) => {
return request.delete<boolean>({ url: '/im/friend/delete', params: { friendUserId } })
export const deleteFriend = (friendUserId: number | string, clear: boolean) => {
return request.delete<boolean>({
url: '/im/friend/delete',
params: { friendUserId, clear }
})
}
// 更新好友信息(备注 / 免打扰 / 联系人置顶)

View File

@ -148,7 +148,7 @@ const props = withDefaults(
presetUser?: UserVO | null
/** 添加来源;参见 ImFriendAddSourceEnum */
addSource?: number
/** 来源附带信息addSource=2群聊时传群名,话术拼为「我是 XX 群的 YY」 */
/** 来源附带信息addSource=ImFriendAddSource.GROUP 时传群名,话术拼为「我是 XX 群的 YY」 */
addSourceExtra?: string
}>(),
{
@ -175,7 +175,9 @@ const message = useMessage()
const currentUserId = computed(() => getCurrentUserId())
/** 搜索结果过滤掉自己;用 v-if 而非 v-show避免 DOM 占位 + 头像无效请求 */
const visibleUsers = computed(() => users.value.filter((u) => u.id !== currentUserId.value))
const visibleUsers = computed(() =>
users.value.filter((user) => user.id !== currentUserId.value)
)
const keyword = ref('')
const users = ref<UserVO[]>([])
const searched = ref(false)

View File

@ -171,8 +171,8 @@
</template>
<script lang="ts" setup>
import { computed, nextTick, ref, watch } from 'vue'
import type { InputInstance } from 'element-plus'
import { computed, h, nextTick, ref, watch } from 'vue'
import { ElCheckbox, ElMessageBox, type InputInstance } from 'element-plus'
import Icon from '@/components/Icon/src/Icon.vue'
import { useMessage } from '@/hooks/web/useMessage'
@ -373,16 +373,39 @@ async function handleUnblock() {
message.success('已移出黑名单')
}
/** 删除联系人:confirm → friendStore.deleteFriend内部级联清会话→ 通知父级关浮层 / 清选中 */
/** 删除联系人:弹自定义确认(含「同时清空聊天记录」选项)→ friendStore.deleteFriend → 通知父级关浮层 / 清选中 */
async function handleDeleteFriend() {
if (!props.user?.id) {
return
}
const target = props.user
//
await message.confirm(`确定删除好友「${target.nickname || ''}」吗?`, '删除联系人')
//
await friendStore.deleteFriend(target.id)
const clearConversation = ref(true)
try {
await ElMessageBox({
title: '删除联系人',
message: () =>
h('div', { class: 'flex flex-col gap-3 text-sm' }, [
h('div', `确定删除好友「${target.nickname || ''}」?`),
h(
ElCheckbox,
{
modelValue: clearConversation.value,
'onUpdate:modelValue': (value: boolean) => {
clearConversation.value = value
}
},
() => '同时清空聊天记录'
)
]),
showCancelButton: true,
confirmButtonText: '删除',
cancelButtonText: '取消',
confirmButtonClass: 'el-button--danger'
})
} catch {
return
}
await friendStore.deleteFriend(target.id, clearConversation.value)
message.success('已删除好友')
emit('deleted', target)
}

View File

@ -63,6 +63,20 @@
:group-id="conversationStore.activeConversation.targetId"
@locate="handleLocate"
/>
<!-- 私聊对方不再是好友删了 / 被删了胶囊嵌在 header 跟群置顶同级点击弹 UserInfoCard -->
<div v-if="showNotFriendBanner" class="message-panel__not-friend-container">
<div class="message-panel__not-friend" @click="handleNotFriendClick">
<span class="message-panel__not-friend-icon">
<Icon icon="ant-design:user-outlined" :size="11" />
</span>
<span>对方还不是你的朋友</span>
<Icon
icon="ep:arrow-right"
:size="12"
class="text-[var(--el-text-color-secondary)]"
/>
</div>
</div>
</div>
<!-- 中间消息列表 -->
@ -150,6 +164,7 @@ import { useMessage } from '@/hooks/web/useMessage'
import { useConversationStore } from '../../../../store/conversationStore'
import { useFriendStore } from '../../../../store/friendStore'
import { useImUiStore } from '../../../../store/uiStore'
import { getMemberDisplayName } from '@/views/im/utils/user'
import { useGroupStore } from '../../../../store/groupStore'
import { ImConversationType } from '@/views/im/utils/constants'
@ -167,6 +182,7 @@ defineOptions({ name: 'ImMessagePanel' })
const conversationStore = useConversationStore()
const friendStore = useFriendStore()
const uiStore = useImUiStore()
const groupStore = useGroupStore()
const message = useMessage()
const listRef = ref<HTMLElement>()
@ -176,6 +192,32 @@ const isGroup = computed(
() => conversationStore.activeConversation?.type === ImConversationType.GROUP
)
/** 私聊会话且对端不是有效好友(删了 / 被删了),顶部展示「对方还不是你的朋友」黄色横幅 */
const showNotFriendBanner = computed(() => {
const conversation = conversationStore.activeConversation
if (!conversation || conversation.type !== ImConversationType.PRIVATE) {
return false
}
return !friendStore.isFriend(conversation.targetId)
})
/** 点击「对方还不是你的朋友」胶囊:打开 UserInfoCard引导用户重新添加 */
function handleNotFriendClick(event: MouseEvent) {
const conversation = conversationStore.activeConversation
if (!conversation) {
return
}
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
uiStore.openUserInfoCard(
{
id: conversation.targetId,
nickname: conversation.name,
avatar: conversation.avatar
},
{ x: rect.left, y: rect.bottom + 4 }
)
}
/**
* 群聊 header 显示的人数优先 groupStore.memberCount无需等成员列表无值再回退 members.length
*
@ -509,6 +551,42 @@ watch(
color: var(--el-color-primary) !important;
}
/* 「对方还不是你的朋友」胶囊:嵌在 header 内跟群置顶同级不占整行padding 跟群置顶 padding 对齐 */
.message-panel__not-friend-container {
flex-shrink: 0;
display: flex;
align-items: flex-start;
padding: 0 16px 8px;
background-color: var(--el-fill-color-light);
}
.message-panel__not-friend {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 10px;
border-radius: 999px;
font-size: 13px;
cursor: pointer;
color: var(--el-text-color-primary);
background-color: var(--el-color-warning-light-9);
transition: background-color 0.15s;
}
.message-panel__not-friend:hover {
background-color: var(--el-color-warning-light-8);
}
/* 圆形小图标,深黄底色配白色 icon —— 跟微信一致的视觉锤 */
.message-panel__not-friend-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
color: #fff;
background-color: var(--el-color-warning);
flex-shrink: 0;
}
/* sticky + translate fit-content transform -50%
UnoCSS 表达 transform+transition value 不太方便这里用最小的 scoped CSS 承接 */
.message-panel__jump-bottom {

View File

@ -53,6 +53,8 @@ export interface FriendNotificationPayload {
displayName?: string
muted?: boolean
pinned?: boolean
// FRIEND_DELETE是否级联清理本端相关数据如私聊会话
clear?: boolean
}
/**
@ -142,7 +144,7 @@ export const useFriendStore = defineStore('imFriendStore', {
// ==================== 远端拉取 ====================
/** 从后端拉取并覆盖本地列表;同步刷新对应私聊会话的展示名 / 头像 + 落 IDBpending 期间复用同一 Promise */
/** 从后端拉取并覆盖本地列表(含 DISABLE 历史好友给已删对话兜底);只同步 ENABLE 的会话信息DISABLE 的不动 —— cascade 清会话由 WS dispatcher 按 payload.clear 处理,避免 fetchFriends 覆盖用户「不清空聊天记录」的选择 */
async fetchFriends(force = false) {
if (this.loaded && !force) {
return
@ -154,16 +156,17 @@ export const useFriendStore = defineStore('imFriendStore', {
.then((list) => {
this.friends = (list || []).map(convertFriend)
this.loaded = true
// 同步 conversationStore 私聊会话的展示名 / 头像 / 免打扰
const conversationStore = useConversationStore()
for (const friend of this.friends) {
if (friend.status === CommonStatusEnum.DISABLE) {
continue
}
conversationStore.updateConversation(ImConversationType.PRIVATE, friend.friendUserId, {
name: getFriendDisplayName(friend),
avatar: friend.avatar,
muted: friend.muted
})
}
// 落本地缓存
this.saveFriends()
})
.finally(() => {
@ -195,27 +198,31 @@ export const useFriendStore = defineStore('imFriendStore', {
/** 同意一条好友申请;后端会双向落库 + 推 FRIEND_ADD本端等通知到达再 upsertFriend */
async agreeFriendRequest(requestId: number) {
await apiAgreeFriendRequest(requestId)
const request = this.findFriendRequest(requestId)
if (request) {
request.handleResult = ImFriendRequestHandleResult.AGREED
request.handleTime = Date.now()
} else {
// 本地列表没这条,按 id 单查兜底
await this.loadFriendRequest(requestId)
}
await this.applyHandleResult(requestId, ImFriendRequestHandleResult.AGREED)
},
/** 拒绝一条好友申请 */
async refuseFriendRequest(requestId: number, handleContent?: string) {
await apiRefuseFriendRequest(requestId, handleContent)
await this.applyHandleResult(requestId, ImFriendRequestHandleResult.REFUSED, handleContent)
},
/** 把 handleResult 应用到本地申请记录;找不到就按 id 单查兜底 upsert避免破坏 id 倒序 */
async applyHandleResult(
requestId: number,
result: number,
handleContent?: string
): Promise<void> {
const request = this.findFriendRequest(requestId)
if (request) {
request.handleResult = ImFriendRequestHandleResult.REFUSED
request.handleContent = handleContent
request.handleResult = result
if (handleContent !== undefined) {
request.handleContent = handleContent
}
request.handleTime = Date.now()
} else {
await this.loadFriendRequest(requestId)
return
}
await this.loadFriendRequest(requestId)
},
/** 拉取「我相关」的好友申请列表首页(页面打开 / 收到 FRIEND_REQUEST_RECEIVED 时刷新pending 期间复用同一 Promise */
@ -290,10 +297,10 @@ export const useFriendStore = defineStore('imFriendStore', {
// ==================== 好友关系操作 ====================
/** 删除好友(单向软删,本端置 DISABLE;级联清理本地私聊会话) */
async deleteFriend(friendUserId: number) {
await apiDeleteFriend(friendUserId)
this.removeFriend(friendUserId)
/** 删除好友(单向软删,本端置 DISABLEclear=true 时级联清理本地相关数据(如私聊会话),并透传后端给多端同步 */
async deleteFriend(friendUserId: number, clear: boolean = true) {
await apiDeleteFriend(friendUserId, clear)
this.removeFriend(friendUserId, clear)
},
/** 切换免打扰:同步会话的 muted 字段,避免会话列表 muted 图标等 1210 推到才更新 */
@ -379,17 +386,18 @@ export const useFriendStore = defineStore('imFriendStore', {
this.saveFriends()
},
/** 本地标记删除WebSocket FRIEND_DELETE 事件触发;同时级联清私聊会话) */
removeFriend(friendUserId: number) {
/** 本地标记删除WebSocket FRIEND_DELETE 事件触发;clear=true 时级联清相关数据如私聊会话) */
removeFriend(friendUserId: number, clear: boolean = true) {
const friend = this.getFriend(friendUserId)
if (friend) {
// blocked 不动,跟后端 deleteFriend0「删好友期间保留拉黑状态」对齐
friend.status = CommonStatusEnum.DISABLE
friend.deleteTime = Date.now()
}
// 级联清理:把对应的私聊会话也软删,避免会话列表里留着已删好友
const conversationStore = useConversationStore()
conversationStore.removePrivateConversation(friendUserId)
if (clear) {
const conversationStore = useConversationStore()
conversationStore.removePrivateConversation(friendUserId)
}
this.saveFriends()
},
@ -417,26 +425,16 @@ export const useFriendStore = defineStore('imFriendStore', {
/** FRIEND_REQUEST_APPROVED(1201):我的申请被同意;按 requestId 更新状态FRIEND_ADD 会另外推) */
applyFriendRequestApprovedNotification(payload: FriendNotificationPayload) {
const request = this.findFriendRequest(payload.requestId!)
if (request) {
request.handleResult = ImFriendRequestHandleResult.AGREED
request.handleTime = Date.now()
} else {
// 本地列表可能没这条(例如刚登录还没进 contact 页),按 id 单查兜底
void this.loadFriendRequest(payload.requestId!)
}
void this.applyHandleResult(payload.requestId!, ImFriendRequestHandleResult.AGREED)
},
/** FRIEND_REQUEST_REJECTED(1202):我的申请被拒绝;按 requestId 更新状态 */
applyFriendRequestRejectedNotification(payload: FriendNotificationPayload) {
const request = this.findFriendRequest(payload.requestId!)
if (request) {
request.handleResult = ImFriendRequestHandleResult.REFUSED
request.handleContent = payload.handleContent
request.handleTime = Date.now()
} else {
void this.loadFriendRequest(payload.requestId!)
}
void this.applyHandleResult(
payload.requestId!,
ImFriendRequestHandleResult.REFUSED,
payload.handleContent
)
},
/** FRIEND_ADD(1204):新增好友;本端拉取好友详情并入库 */
@ -444,9 +442,9 @@ export const useFriendStore = defineStore('imFriendStore', {
void this.loadFriendInfo(payload.friendUserId)
},
/** FRIEND_DELETE(1205):好友被删除;本端清理 + 级联会话 */
/** FRIEND_DELETE(1205):好友被删除;本端清理 + 按 payload.clear 决定是否级联会话(多端跟主操作端一致) */
applyFriendDeleteNotification(payload: FriendNotificationPayload) {
this.removeFriend(payload.friendUserId)
this.removeFriend(payload.friendUserId, payload.clear !== false)
},
/** FRIEND_BLOCK(1207):拉黑;多端同步 */