✨ feat(im): 增加好友申请的逻辑(v1.2:增加相关枚举、字典,减少硬编码)
parent
5b9acb4813
commit
89ee5d51ea
|
|
@ -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 群成员角色
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
||||
|
|
|
|||
|
|
@ -8,14 +8,13 @@
|
|||
class="relative flex flex-col items-center px-0.5 py-1"
|
||||
:style="{ width: `${size! + 16}px` }"
|
||||
>
|
||||
<!-- TODO @AI:add 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' })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
/** 添加来源文案;对齐后端 ImFriendAddSourceEnum:1 搜索 / 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)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@
|
|||
-->
|
||||
<div>
|
||||
<!-- 折叠分组头 -->
|
||||
<!-- TODO @AI:tool 那,应该因为通讯录(新的朋友)有个计数;未处理数量; -->
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 @AI:1 要走枚举
|
||||
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 @AI:1 要走枚举
|
||||
addSource: number = 1,
|
||||
addSource: number = ImFriendAddSource.SEARCH,
|
||||
addSourceExtra: string = ''
|
||||
) {
|
||||
const viewportWidth = document.documentElement.clientWidth
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue