feat(im): 增加好友申请的逻辑(v1.2:增加相关枚举、字典,减少硬编码)

im
YunaiV 2026-05-04 10:44:09 +08:00
parent 5b9acb4813
commit 89ee5d51ea
12 changed files with 92 additions and 66 deletions

View File

@ -333,6 +333,8 @@ export enum DICT_TYPE {
IM_GROUP_MESSAGE_STATUS = 'im_group_message_status', // IM 群聊消息状态0=正常 / 2=已撤回
IM_GROUP_MESSAGE_RECEIPT_STATUS = 'im_group_message_receipt_status', // IM 群消息回执状态
IM_FRIEND_STATUS = 'im_friend_status', // IM 好友状态
IM_FRIEND_ADD_SOURCE = 'im_friend_add_source', // IM 好友添加来源
IM_FRIEND_REQUEST_HANDLE_RESULT = 'im_friend_request_handle_result', // IM 好友申请处理结果
IM_GROUP_STATUS = 'im_group_status', // IM 群状态
IM_GROUP_MEMBER_ROLE = 'im_group_member_role' // IM 群成员角色
}

View File

@ -30,6 +30,14 @@
>
<Icon :icon="item.icon" :size="22" />
</el-badge>
<el-badge
v-else-if="item.name === 'ImHomeContact' && unhandledRequestCount > 0"
:value="unhandledRequestCount"
:max="99"
class="tool-bar__badge"
>
<Icon :icon="item.icon" :size="22" />
</el-badge>
<Icon v-else :icon="item.icon" :size="22" />
</div>
</el-tooltip>
@ -55,6 +63,7 @@ import { useRoute, useRouter } from 'vue-router'
import Icon from '@/components/Icon/src/Icon.vue'
import { useUserStore } from '@/store/modules/user'
import { useConversationStore } from '../store/conversationStore'
import { useFriendStore } from '../store/friendStore'
import UserAvatar from './user/UserAvatar.vue'
defineOptions({ name: 'ImToolBar' })
@ -63,15 +72,15 @@ const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const conversationStore = useConversationStore()
const friendStore = useFriendStore()
/** 消息 Tab 的红点:所有非免打扰会话的未读总和 */
const totalUnread = computed(() => conversationStore.getTotalUnread)
const totalUnread = computed(() => conversationStore.getTotalUnread) // Tab
const unhandledRequestCount = computed(() => friendStore.getUnhandledRequestCount) // Tab =
/** 两个主 Tab用路由 name 而非 path避免前缀 / 嵌套调整后失效 */
const tabs = [
{ name: 'ImHomeConversation', label: '消息', icon: 'ep:chat-round' },
{ name: 'ImHomeContact', label: '通讯录', icon: 'mingcute:contacts-line' }
]
] // 两个主 Tab用路由 name 而非 path避免前缀 / 嵌套调整后失效
/** 当前路由是否命中 Tab直接比对 route.name */
const isActive = (name: string) => route.name === name

View File

@ -136,6 +136,7 @@ import { useUserStore } from '@/store/modules/user'
import UserAvatar from '../user/UserAvatar.vue'
import { useFriendStore } from '../../store/friendStore'
import { getCurrentUserId } from '../../../utils/storage'
import { ImFriendAddSource } from '../../../utils/constants'
import { getGenderColor, getGenderIcon } from '../../../utils/user'
import { getSimpleUserListByNickname, type UserVO } from '@/api/system/user'
@ -153,7 +154,7 @@ const props = withDefaults(
}>(),
{
presetUser: null,
addSource: 1
addSource: ImFriendAddSource.SEARCH
}
)
@ -226,8 +227,7 @@ function buildPresetApplyContent(): string {
return ''
}
// YY
// TODO @AI使 addSource
const groupExtra = props.addSource === 2 ? props.addSourceExtra : ''
const groupExtra = props.addSource === ImFriendAddSource.GROUP ? props.addSourceExtra : ''
return groupExtra ? `我是"${groupExtra}"的${myNickname}` : `我是${myNickname}`
}

View File

@ -14,7 +14,7 @@
:url="member.avatar"
:clickable="clickable"
:id="member.userId"
:add-source="2"
:add-source="ImFriendAddSource.GROUP"
:add-source-extra="groupName"
/>
<div
@ -30,6 +30,7 @@
import { computed } from 'vue'
import UserAvatar from '../user/UserAvatar.vue'
import { ImFriendAddSource } from '../../../utils/constants'
defineOptions({ name: 'ImGroupMember' })

View File

@ -8,14 +8,13 @@
class="relative flex flex-col items-center px-0.5 py-1"
:style="{ width: `${size! + 16}px` }"
>
<!-- TODO @AIadd source 增加枚举 -->
<UserAvatar
:id="member.userId"
:url="member.avatar"
:name="member.nickname"
:size="size"
:clickable="clickable"
:add-source="2"
:add-source="ImFriendAddSource.GROUP"
:add-source-extra="groupName"
/>
<div
@ -29,6 +28,7 @@
<script lang="ts" setup>
import UserAvatar from '../user/UserAvatar.vue'
import { ImFriendAddSource } from '../../../utils/constants'
import type { GroupMemberLite } from './GroupMember.vue'
defineOptions({ name: 'ImGroupMemberGrid' })

View File

@ -45,6 +45,7 @@
import { computed } from 'vue'
import { useImUiStore } from '../../store/uiStore'
import { ImFriendAddSource } from '../../../utils/constants'
import type { User } from '../../types'
defineOptions({ name: 'ImUserAvatar', inheritAttrs: false })
@ -69,7 +70,7 @@ const props = withDefaults(
clickable: true,
previewable: false,
previewZIndex: 2000,
addSource: 1 // @AI
addSource: ImFriendAddSource.SEARCH
}
)

View File

@ -295,7 +295,12 @@ function handleChat() {
emit('chat', props.user)
}
// TODO @AI ====
/** 占位提示:语音 / 视频聊天能力尚未接入,先以"开发中"友好提示 */
function handleComingSoon(featureName: string) {
message.info(`${featureName} 功能开发中`)
}
// ==================== / ====================
// + props.user FriendAddDialog
const addFriendVisible = ref(false)
@ -330,11 +335,6 @@ async function handleDeleteFriend() {
message.success('已删除好友')
emit('deleted', target)
}
/** 占位提示:语音 / 视频聊天能力尚未接入,先以"开发中"友好提示 */
function handleComingSoon(featureName: string) {
message.info(`${featureName} 功能开发中`)
}
</script>
<style>

View File

@ -34,39 +34,47 @@
<span class="w-12 flex-shrink-0">来源</span>
<span class="text-[var(--el-text-color-primary)]">{{ addSourceLabel }}</span>
</div>
<!-- 拒绝理由拒绝状态展示 -->
<!-- 拒绝理由拒绝状态展示长文本走 break-words 自动折行避免横向溢出 -->
<!-- TODO @AI会折行拒绝理由 -->
<!-- TODO @AI我指的是 -->
<!-- TODO @AI尽量对齐下微信的样式 /Users/yunai/Downloads/iShot_2026-05-04_10.42.58.png
/Users/yunai/Downloads/iShot_2026-05-04_10.42.46.png -->
<div
v-if="request.handleResult === 2 && request.handleContent"
v-if="request.handleResult === ImFriendRequestHandleResult.REFUSED && request.handleContent"
class="w-full max-w-[420px] mt-3 flex items-start text-13px text-[var(--el-text-color-secondary)]"
>
<span class="w-12 flex-shrink-0">拒绝理由</span>
<span class="text-[var(--el-text-color-primary)]">{{ request.handleContent }}</span>
<span class="flex-1 min-w-0 break-words text-[var(--el-text-color-primary)]">
{{ request.handleContent }}
</span>
</div>
<!-- 操作按钮 -->
<div class="w-full max-w-[420px] mt-8 flex justify-center">
<!-- 我发起 + 等待中禁用等待对方验证 -->
<el-button v-if="iSentIt && request.handleResult === 0" disabled>
<el-button
v-if="iSentIt && request.handleResult === ImFriendRequestHandleResult.UNHANDLED"
disabled
>
等待对方验证
</el-button>
<!-- 别人加我 + 等待中同意 / 拒绝 -->
<template v-if="!iSentIt && request.handleResult === 0">
<template v-if="!iSentIt && request.handleResult === ImFriendRequestHandleResult.UNHANDLED">
<el-button @click="handleRefuse" :loading="refusing">拒绝</el-button>
<el-button type="primary" @click="handleAgree" :loading="agreeing">
同意
</el-button>
<el-button type="primary" @click="handleAgree" :loading="agreeing"> 同意 </el-button>
</template>
<!-- 已同意发消息 -->
<el-button
v-if="request.handleResult === 1"
v-if="request.handleResult === ImFriendRequestHandleResult.AGREED"
type="primary"
@click="emit('chat', peerUserId)"
>
发消息
</el-button>
<!-- 已拒绝占位禁用按钮 -->
<el-button v-if="request.handleResult === 2" disabled>已拒绝</el-button>
<el-button v-if="request.handleResult === ImFriendRequestHandleResult.REFUSED" disabled>
已拒绝
</el-button>
</div>
</div>
</template>
@ -79,6 +87,8 @@ import { ElMessageBox } from 'element-plus'
import UserAvatar from '../../components/user/UserAvatar.vue'
import { useFriendStore } from '../../store/friendStore'
import { getCurrentUserId } from '../../../utils/storage'
import { ImFriendRequestHandleResult } from '../../../utils/constants'
import { DICT_TYPE, getDictLabel } from '@/utils/dict'
import type { FriendRequest } from '../../types'
defineOptions({ name: 'ImContactFriendRequestDetail' })
@ -112,22 +122,13 @@ const peerAvatar = computed(() =>
iSentIt.value ? props.request.toAvatar : props.request.fromAvatar
)
/** 添加来源文案;对齐后端 ImFriendAddSourceEnum1 搜索 / 2 群聊 / 3 扫码 / 4 名片 */
// TODO @AI
const addSourceLabel = computed(() => {
switch (props.request.addSource) {
case 1:
return '通过搜索添加'
case 2:
return '通过群聊添加'
case 3:
return '通过扫码添加'
case 4:
return '通过名片添加'
default:
return ''
}
})
/** 添加来源文案:走字典,对齐后端 ImFriendAddSourceEnum */
// TODO @AI html
const addSourceLabel = computed(() =>
props.request.addSource
? getDictLabel(DICT_TYPE.IM_FRIEND_ADD_SOURCE, props.request.addSource)
: ''
)
const agreeing = ref(false)
const refusing = ref(false)
@ -143,9 +144,9 @@ async function handleAgree() {
}
}
/** 拒绝申请 */
/** 拒绝申请:弹 prompt 收集可选拒绝理由(点取消则中止),随后调 store 落库 + 提示 */
async function handleRefuse() {
// TODO @AI system user index.vue
// 1. prompt 255 reject
let handleContent: string | undefined
try {
const result = await ElMessageBox.prompt('可填写拒绝理由(选填)', '拒绝好友申请', {
@ -160,6 +161,7 @@ async function handleRefuse() {
} catch {
return
}
// 2. store loading
refusing.value = true
try {
await friendStore.refuseFriendRequest(props.request.id, handleContent)

View File

@ -7,7 +7,6 @@
-->
<div>
<!-- 折叠分组头 -->
<!-- TODO @AItool 应该因为通讯录新的朋友有个计数未处理数量 -->
<div
class="flex gap-2 items-center px-3.5 py-2.5 text-15px text-[var(--el-text-color-primary)] cursor-pointer select-none hover:bg-[var(--el-fill-color-light)]"
@click="expanded = !expanded"
@ -67,6 +66,8 @@ import { computed, ref } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import UserAvatar from '../../components/user/UserAvatar.vue'
import { getCurrentUserId } from '../../../utils/storage'
import { ImFriendRequestHandleResult } from '../../../utils/constants'
import { DICT_TYPE, getDictLabel } from '@/utils/dict'
import type { FriendRequest } from '../../types'
defineOptions({ name: 'ImContactFriendRequestList' })
@ -81,12 +82,13 @@ const emit = defineEmits<{
}>()
const expanded = ref(true)
// TODO @AI Number
const currentUserId = Number(getCurrentUserId() || 0)
const currentUserId = getCurrentUserId()
/** 未处理 + 别人加我的(接收方=我)才进红点;我发起的不进 */
const unhandledCount = computed(
() => props.requests.filter((r) => r.handleResult === 0 && r.toUserId === currentUserId).length
() => props.requests.filter(
(r) => r.handleResult === ImFriendRequestHandleResult.UNHANDLED && r.toUserId === currentUserId
).length
)
/** 列表项展示对端fromUserId == 我 → 对端 = toUser否则对端 = fromUser */
@ -94,27 +96,21 @@ function getPeerUserId(request: FriendRequest): number {
return request.fromUserId === currentUserId ? request.toUserId : request.fromUserId
}
// TODO @AI
/** 列表项展示对端的昵称fromUserId == 我 → toUser 昵称;否则 fromUser 昵称;缺则用 id 兜底) */
function getPeerNickname(request: FriendRequest): string {
return request.fromUserId === currentUserId
? request.toNickname || String(request.toUserId)
: request.fromNickname || String(request.fromUserId)
}
// TODO @AI
/** 列表项展示对端的头像fromUserId == 我 → toUser 头像;否则 fromUser 头像) */
function getPeerAvatar(request: FriendRequest): string | undefined {
return request.fromUserId === currentUserId ? request.toAvatar : request.fromAvatar
}
/** 状态文案:未处理 / 同意 / 拒绝 */
// TODO @AI
/** 状态文案:走字典,对齐后端 ImFriendRequestHandleResultEnum */
// TODO @AI html vue
function statusLabel(request: FriendRequest): string {
if (request.handleResult === 1) {
return '已添加'
}
if (request.handleResult === 2) {
return '已拒绝'
}
return '等待验证'
return getDictLabel(DICT_TYPE.IM_FRIEND_REQUEST_HANDLE_RESULT, request.handleResult)
}
</script>

View File

@ -21,12 +21,12 @@
<div class="text-13px text-[var(--el-text-color-secondary)]"> {{ memberCount }} 位成员 </div>
<!-- 成员宫格纯展示宽度跟着 320 容器自动换行不带"邀请 +"瓦片 -->
<div class="flex flex-wrap gap-2 justify-center w-full pt-2">
<!-- TODO @AI是不是传入 groupname 更合适不应该给别人看到我自己自定义的 -->
<!-- 注意加好友话术里的群名一律用 group.name原始名showGroupName 是我自定义的群备注不能带给对端 -->
<GroupMemberGrid
v-for="member in members"
:key="member.userId"
:member="member"
:group-name="group.showGroupName || group.name"
:group-name="group.name"
/>
</div>
<div class="mt-4">

View File

@ -1,6 +1,7 @@
import { defineStore, acceptHMRUpdate } from 'pinia'
import { reactive } from 'vue'
import { ImFriendAddSource } from '../../utils/constants'
import type { User } from '../types'
/**
@ -18,8 +19,8 @@ export const useImUiStore = defineStore('imUiStore', () => {
show: false,
user: null as User | null,
position: { x: 0, y: 0 },
// TODO @AI1 要走枚举
addSource: 1 as number, // addSource / addSourceExtra 跟随触发点带入「加好友」来源(群成员入口 = GROUP + 群名;其余默认搜索)
// addSource / addSourceExtra 跟随触发点带入「加好友」来源(群成员入口 = GROUP + 群名;其余默认搜索)
addSource: ImFriendAddSource.SEARCH as number,
addSourceExtra: '' as string
})
@ -27,8 +28,7 @@ export const useImUiStore = defineStore('imUiStore', () => {
function openUserInfoCard(
user: User,
position: { x: number; y: number },
// TODO @AI1 要走枚举
addSource: number = 1,
addSource: number = ImFriendAddSource.SEARCH,
addSourceExtra: string = ''
) {
const viewportWidth = document.documentElement.clientWidth

View File

@ -114,6 +114,21 @@ export const ImGroupMemberRole = {
NORMAL: 3 // 普通成员
} as const
/** 好友添加来源(对齐后端 ImFriendAddSourceEnum */
export const ImFriendAddSource = {
SEARCH: 1, // 搜索
GROUP: 2, // 群聊
QR_CODE: 3, // 扫码
CARD: 4 // 名片
} as const
/** 好友申请处理结果(对齐后端 ImFriendRequestHandleResultEnum */
export const ImFriendRequestHandleResult = {
UNHANDLED: 0, // 未处理
AGREED: 1, // 同意
REFUSED: 2 // 拒绝
} as const
/** 群管理员人数上限(对齐后端 GROUP_ADMIN_MAX_COUNT */
export const GROUP_ADMIN_MAX_COUNT = 3