From 1469d8bb3deed5c3583d8aace9dd75b91fc1deb1 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Mon, 4 May 2026 09:47:25 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(im):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=A5=BD=E5=8F=8B=E7=94=B3=E8=AF=B7=E7=9A=84=E9=80=BB=E8=BE=91?= =?UTF-8?q?=EF=BC=88v1.1=EF=BC=9A=E5=A2=9E=E5=8A=A0=E5=90=84=E7=A7=8D=20co?= =?UTF-8?q?de=20review=20=E6=B3=A8=E9=87=8A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/friend/FriendAddDialog.vue | 275 +++++++++++++----- .../im/home/components/group/GroupMember.vue | 6 +- .../home/components/group/GroupMemberGrid.vue | 7 +- .../im/home/components/user/UserAvatar.vue | 16 +- .../im/home/components/user/UserInfo.vue | 40 ++- .../im/home/components/user/UserInfoCard.vue | 2 + .../pages/contact/FriendRequestDetail.vue | 171 +++++++++++ .../home/pages/contact/FriendRequestList.vue | 120 ++++++++ .../im/home/pages/contact/GroupDetail.vue | 8 +- src/views/im/home/pages/contact/index.vue | 61 +++- .../conversation/ConversationGroupSide.vue | 1 + src/views/im/home/store/uiStore.ts | 18 +- 12 files changed, 635 insertions(+), 90 deletions(-) create mode 100644 src/views/im/home/pages/contact/FriendRequestDetail.vue create mode 100644 src/views/im/home/pages/contact/FriendRequestList.vue diff --git a/src/views/im/home/components/friend/FriendAddDialog.vue b/src/views/im/home/components/friend/FriendAddDialog.vue index 9b7c26d7f..23a50b99e 100644 --- a/src/views/im/home/components/friend/FriendAddDialog.vue +++ b/src/views/im/home/components/friend/FriendAddDialog.vue @@ -1,75 +1,129 @@ @@ -77,6 +131,7 @@ import { computed, ref, watch } from 'vue' import Icon from '@/components/Icon/src/Icon.vue' import { useMessage } from '@/hooks/web/useMessage' +import { useUserStore } from '@/store/modules/user' import UserAvatar from '../user/UserAvatar.vue' import { useFriendStore } from '../../store/friendStore' @@ -86,9 +141,21 @@ import { getSimpleUserListByNickname, type UserVO } from '@/api/system/user' defineOptions({ name: 'ImFriendAddDialog' }) -const props = defineProps<{ - modelValue: boolean -}>() +const props = withDefaults( + defineProps<{ + modelValue: boolean + /** 预填目标用户:不为空时跳过搜索步骤,直接进入申请表单(群成员加好友 / 名片加好友等场景) */ + presetUser?: UserVO | null + /** 添加来源;参见 ImFriendAddSourceEnum */ + addSource?: number + /** 来源附带信息:addSource=2(群聊)时传群名,话术拼为「我是 XX 群的 YY」 */ + addSourceExtra?: string + }>(), + { + presetUser: null, + addSource: 1 + } +) const emit = defineEmits<{ 'update:modelValue': [value: boolean] @@ -101,6 +168,7 @@ const visible = computed({ }) const friendStore = useFriendStore() +const userStore = useUserStore() const message = useMessage() const currentUserId = getCurrentUserId() // 当前登录用户编号 @@ -109,15 +177,60 @@ const users = ref([]) const searched = ref(false) const loading = ref(false) -// 每次重新打开都把搜索态清空,避免上次的关键字 / 结果泄漏到下次 +/** 当前步骤:search=搜索列表;apply=申请表单 */ +const step = ref<'search' | 'apply'>('search') +/** 申请目标用户 */ +const targetUser = ref(null) +/** 申请理由(默认填「我是 ${当前昵称}」,对齐微信交互) */ +const applyContent = ref('') +/** 对接收方的备注(仅自己可见) */ +const displayName = ref('') +const submitting = ref(false) + +/** 弹窗标题随步骤切换 */ +const dialogTitle = computed(() => (step.value === 'apply' ? '申请添加朋友' : '添加好友')) + +/** 是否预填模式(presetUser 不为空 → 跳过搜索,关闭即销毁,无「取消返回搜索」按钮) */ +const presetMode = computed(() => !!props.presetUser) + +/** 每次重新打开都把搜索态 / 申请态清空,避免上次的数据泄漏到下次 */ watch(visible, (open) => { if (open) { - keyword.value = '' - users.value = [] - searched.value = false + resetAll() } }) +function resetAll() { + keyword.value = '' + users.value = [] + searched.value = false + // 预填模式:直接进申请表单,targetUser 取自 presetUser;申请理由按 addSource 区分话术 + if (props.presetUser) { + targetUser.value = props.presetUser + applyContent.value = buildPresetApplyContent() + displayName.value = '' + step.value = 'apply' + return + } + // 非预填模式:默认进搜索步骤 + step.value = 'search' + targetUser.value = null + applyContent.value = '' + displayName.value = '' +} + +/** 预填模式下的申请理由话术:群聊「我是"XX 群"的 YY」;其它「我是 YY」 */ +function buildPresetApplyContent(): string { + const myNickname = userStore.getUser?.nickname || '' + if (!myNickname) { + return '' + } + // 群聊场景拼带群名的话术;其它场景默认「我是 YY」 + // TODO @AI:使用 addSource 的枚举; + const groupExtra = props.addSource === 2 ? props.addSourceExtra : '' + return groupExtra ? `我是"${groupExtra}"的${myNickname}` : `我是${myNickname}` +} + /** 按昵称搜索用户:空关键字直接清空结果 */ async function handleSearch() { searched.value = true @@ -133,12 +246,38 @@ async function handleSearch() { } } -/** 发起好友申请:成功后 friendStore 已落地,按钮自动切到 "已添加" */ -async function handleAdd(user: UserVO) { - await friendStore.addFriend(user.id, { - nickname: user.nickname, - avatar: user.avatar - }) - message.success('已添加好友') +/** 进入申请步骤:预填申请理由「我是 ${当前用户昵称}」(对齐微信交互) */ +function enterApply(user: UserVO) { + targetUser.value = user + const myNickname = userStore.getUser?.nickname || '' + applyContent.value = myNickname ? `我是${myNickname}` : '' + displayName.value = '' + step.value = 'apply' +} + +/** 取消申请,回到搜索步骤 */ +function backToSearch() { + step.value = 'search' + targetUser.value = null +} + +/** 提交好友申请 */ +async function handleSubmitApply() { + if (!targetUser.value) { + return + } + submitting.value = true + try { + await friendStore.applyFriend({ + toUserId: targetUser.value.id, + applyContent: applyContent.value.trim() || undefined, + displayName: displayName.value.trim() || undefined, + addSource: props.addSource + }) + message.success('申请已发送,等待对方验证') + visible.value = false + } finally { + submitting.value = false + } } diff --git a/src/views/im/home/components/group/GroupMember.vue b/src/views/im/home/components/group/GroupMember.vue index d917e8696..363c48917 100644 --- a/src/views/im/home/components/group/GroupMember.vue +++ b/src/views/im/home/components/group/GroupMember.vue @@ -14,6 +14,8 @@ :url="member.avatar" :clickable="clickable" :id="member.userId" + :add-source="2" + :add-source-extra="groupName" />
(), { height: 50, active: false, - clickable: false + clickable: false, + groupName: '' } ) diff --git a/src/views/im/home/components/group/GroupMemberGrid.vue b/src/views/im/home/components/group/GroupMemberGrid.vue index 5d304741a..20d2c30bc 100644 --- a/src/views/im/home/components/group/GroupMemberGrid.vue +++ b/src/views/im/home/components/group/GroupMemberGrid.vue @@ -8,12 +8,15 @@ class="relative flex flex-col items-center px-0.5 py-1" :style="{ width: `${size! + 16}px` }" > +
(), { clickable: false, - size: 38 + size: 38, + groupName: '' } ) diff --git a/src/views/im/home/components/user/UserAvatar.vue b/src/views/im/home/components/user/UserAvatar.vue index ed2cf0daf..4be0aed77 100644 --- a/src/views/im/home/components/user/UserAvatar.vue +++ b/src/views/im/home/components/user/UserAvatar.vue @@ -60,13 +60,16 @@ const props = withDefaults( previewable?: boolean // 是否点头像直接放大预览;开启后忽略 clickable,不再弹名片 previewZIndex?: number // 预览层 z-index;放在高 z-index 弹层(如 UserInfoCard)里时需手动抬高 user?: User // 额外的用户信息,传了点击就不用现拉接口(弹名片用) + addSource?: number // 加好友来源;点头像弹 UserInfoCard 时透传给 FriendAddDialog(默认 1=搜索) + addSourceExtra?: string // 加好友来源附加:addSource=2(群聊)时传群名,话术拼为「我是 XX 群的 YY」 }>(), { size: 42, radius: '15%', clickable: true, previewable: false, - previewZIndex: 2000 + previewZIndex: 2000, + addSource: 1 // @AI:是不是枚举下; } ) @@ -126,7 +129,12 @@ function handleClick(e: MouseEvent) { } // 情况一:有预传 user 信息:就直接用,省一次接口 if (props.user) { - uiStore.openUserInfoCard(props.user, { x: e.clientX + 20, y: e.clientY }) + uiStore.openUserInfoCard( + props.user, + { x: e.clientX + 20, y: e.clientY }, + props.addSource, + props.addSourceExtra + ) return } // 情况二:无预传 user 信息:打开名片,传最小必要信息(id + 昵称 + 头像),位置在鼠标右侧 @@ -140,7 +148,9 @@ function handleClick(e: MouseEvent) { nickname: props.name, avatar: props.url }, - { x: e.clientX + 20, y: e.clientY } + { x: e.clientX + 20, y: e.clientY }, + props.addSource, + props.addSourceExtra ) } diff --git a/src/views/im/home/components/user/UserInfo.vue b/src/views/im/home/components/user/UserInfo.vue index a9535dc46..237b176e1 100644 --- a/src/views/im/home/components/user/UserInfo.vue +++ b/src/views/im/home/components/user/UserInfo.vue @@ -139,6 +139,14 @@ 加为好友
+ + +
@@ -149,7 +157,8 @@ import Icon from '@/components/Icon/src/Icon.vue' import { useMessage } from '@/hooks/web/useMessage' import UserAvatar from './UserAvatar.vue' -import { getSimpleUser } from '@/api/system/user' +import FriendAddDialog from '../friend/FriendAddDialog.vue' +import { getSimpleUser, type UserVO } from '@/api/system/user' import { useFriendStore } from '../../store/friendStore' import { getGenderColor, getGenderIcon } from '../../../utils/user' import type { User } from '../../types' @@ -174,10 +183,15 @@ const props = withDefaults( displayName?: string /** UserAvatar 预览层 z-index;放在高 z-index 浮层(如 UserInfoCard)里需手动抬高 */ previewZIndex?: number + /** 加好友来源:1=搜索 2=群聊 3=扫码 4=名片;默认 1(搜索);参见 ImFriendAddSourceEnum */ + addSource?: number + /** 来源附带信息:addSource=2(群聊)时传群名,用于「我是 XX 群的 YY」预填话术 */ + addSourceExtra?: string }>(), { relation: 'readonly', - previewZIndex: 2000 + previewZIndex: 2000, + addSource: 1 } ) @@ -281,16 +295,26 @@ function handleChat() { emit('chat', props.user) } -/** 加为好友:成功后 friendStore 反应到 isFriend,父级的 relation 自然翻 friend,本组件随之换装到 3 图标 */ -async function handleAddFriend() { +// TODO @AI:添加好友、删除好友,作为一个 ==== 栏目,这样好理解点; + +// 加好友弹窗显隐 + 预填用户(点「加为好友」时把 props.user 传给 FriendAddDialog 跳过搜索) +const addFriendVisible = ref(false) +const presetUserForAdd = ref(null) + +/** 加为好友:弹 FriendAddDialog(带预填用户),让用户填申请理由 + 备注后再发申请 */ +function handleAddFriend() { if (!props.user?.id) { return } - await friendStore.addFriend(props.user.id, { + presetUserForAdd.value = { + id: props.user.id, nickname: props.user.nickname, - avatar: props.user.avatar - }) - message.success('已添加好友') + avatar: props.user.avatar, + sex: props.user.sex, + deptId: props.user.deptId, + deptName: props.user.deptName + } as UserVO + addFriendVisible.value = true } /** 删除联系人:confirm → friendStore.deleteFriend(内部级联清会话)→ 通知父级关浮层 / 清选中 */ diff --git a/src/views/im/home/components/user/UserInfoCard.vue b/src/views/im/home/components/user/UserInfoCard.vue index ad1ff7582..a5da452fc 100644 --- a/src/views/im/home/components/user/UserInfoCard.vue +++ b/src/views/im/home/components/user/UserInfoCard.vue @@ -17,6 +17,8 @@ :display-name="remark" :relation="relation" :preview-z-index="10000" + :add-source="card.addSource" + :add-source-extra="card.addSourceExtra" @chat="handleSendMessage" @deleted="handleClose" /> diff --git a/src/views/im/home/pages/contact/FriendRequestDetail.vue b/src/views/im/home/pages/contact/FriendRequestDetail.vue new file mode 100644 index 000000000..9999b0c23 --- /dev/null +++ b/src/views/im/home/pages/contact/FriendRequestDetail.vue @@ -0,0 +1,171 @@ + + + diff --git a/src/views/im/home/pages/contact/FriendRequestList.vue b/src/views/im/home/pages/contact/FriendRequestList.vue new file mode 100644 index 000000000..b2581169e --- /dev/null +++ b/src/views/im/home/pages/contact/FriendRequestList.vue @@ -0,0 +1,120 @@ + + + diff --git a/src/views/im/home/pages/contact/GroupDetail.vue b/src/views/im/home/pages/contact/GroupDetail.vue index 2be106967..42cb51532 100644 --- a/src/views/im/home/pages/contact/GroupDetail.vue +++ b/src/views/im/home/pages/contact/GroupDetail.vue @@ -21,7 +21,13 @@
{{ memberCount }} 位成员
- + +
进入群聊 diff --git a/src/views/im/home/pages/contact/index.vue b/src/views/im/home/pages/contact/index.vue index f2dbb0aa1..91becbbab 100644 --- a/src/views/im/home/pages/contact/index.vue +++ b/src/views/im/home/pages/contact/index.vue @@ -18,8 +18,13 @@
- + + + + @@ -83,13 +94,15 @@ import { useRouter } from 'vue-router' import ResizableAside from '../../components/ResizableAside.vue' import UserInfo from '../../components/user/UserInfo.vue' import FriendList from './FriendList.vue' +import FriendRequestList from './FriendRequestList.vue' +import FriendRequestDetail from './FriendRequestDetail.vue' import GroupList from './GroupList.vue' import GroupDetail from './GroupDetail.vue' import { useConversationStore } from '../../store/conversationStore' import { useFriendStore } from '../../store/friendStore' import { useGroupStore } from '../../store/groupStore' import { getFriendDisplayName, getGroupDisplayName } from '../../../utils/user' -import type { Friend, FriendLite, Group, GroupLite, User } from '../../types' +import type { Friend, FriendLite, FriendRequest, Group, GroupLite, User } from '../../types' import { ImConversationType } from '../../../utils/constants' import { StorageKeys } from '../../../utils/storage' import { CommonStatusEnum } from '@/utils/constants' @@ -102,12 +115,27 @@ const friendStore = useFriendStore() const groupStore = useGroupStore() const message = useMessage() -/** 用 type 判别选中是好友还是群聊 */ -type Selection = { type: 'friend'; friend: FriendLite } | { type: 'group'; group: GroupLite } +/** 用 type 判别选中是好友 / 群聊 / 好友申请 */ +type Selection = + | { type: 'friend'; friend: FriendLite } + | { type: 'group'; group: GroupLite } + | { type: 'request'; request: FriendRequest } const selection = ref(null) const keyword = ref('') +/** 选中申请详情:详情用 store 里的最新副本(同意 / 拒绝后状态会变) */ +const currentRequest = computed(() => { + const req = selection.value?.type === 'request' ? selection.value.request : null + if (!req) { + return {} as FriendRequest + } + return friendStore.findFriendRequest(req.id) || req +}) + +/** 我相关的申请列表(用 friendStore 里的实时副本,便于通知到达后自动刷新) */ +const friendRequests = computed(() => friendStore.friendRequests) + /** 好友列表的展示快照:附带后端算好的拼音,给 FriendList 做字母分桶 / 拼音搜索 */ const friends = computed(() => friendStore.getActiveFriends.map((friend: Friend) => ({ @@ -145,7 +173,11 @@ const friendUser = computed(() => { }) onMounted(async () => { - await Promise.all([friendStore.fetchFriends(), groupStore.fetchGroups()]) + await Promise.all([ + friendStore.fetchFriends(), + friendStore.fetchFriendRequests(), + groupStore.fetchGroups() + ]) }) /** 选中好友 → 切到好友详情 */ @@ -158,6 +190,25 @@ function handleSelectGroup(group: GroupLite) { selection.value = { type: 'group', group } } +/** 选中好友申请 → 切到「新的朋友」详情 */ +function handleSelectRequest(request: FriendRequest) { + selection.value = { type: 'request', request } +} + +/** 申请详情里点「发消息」:直接进与对端的私聊会话 */ +function handleChatPeer(peerUserId: number) { + const friend = friendStore.getFriend(peerUserId) + const conversationName = friend ? getFriendDisplayName(friend) : String(peerUserId) + conversationStore.openConversation( + peerUserId, + ImConversationType.PRIVATE, + conversationName, + friend?.avatar || '', + { muted: !!friend?.muted } + ) + router.push({ name: 'ImHomeConversation' }) +} + /** 进入与该好友的私聊会话 */ function handleChatFriend(friend: FriendLite) { // 从 friendStore 同步备注 + 免打扰,避免新建会话用过期数据 diff --git a/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue b/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue index 4ecda7bc0..7022fbd9d 100644 --- a/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue +++ b/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue @@ -29,6 +29,7 @@ :member="member" :size="50" clickable + :group-name="group.name" /> diff --git a/src/views/im/home/store/uiStore.ts b/src/views/im/home/store/uiStore.ts index 321b8c33f..88f888b50 100644 --- a/src/views/im/home/store/uiStore.ts +++ b/src/views/im/home/store/uiStore.ts @@ -12,20 +12,32 @@ import type { User } from '../types' */ export const useImUiStore = defineStore('imUiStore', () => { // ==================== 用户名片 UserInfoCard ==================== - // 用户名片悬浮卡:头像 / 昵称等触发点遍布会话、群成员、@ 选择器等列表, + // 用户名片悬浮卡 + const userInfoCard = reactive({ show: false, user: null as User | null, - position: { x: 0, y: 0 } + position: { x: 0, y: 0 }, + // TODO @AI:1 要走枚举 + addSource: 1 as number, // addSource / addSourceExtra 跟随触发点带入「加好友」来源(群成员入口 = GROUP + 群名;其余默认搜索) + addSourceExtra: '' as string }) /** 打开用户名片 */ - function openUserInfoCard(user: User, position: { x: number; y: number }) { + function openUserInfoCard( + user: User, + position: { x: number; y: number }, + // TODO @AI:1 要走枚举 + addSource: number = 1, + addSourceExtra: string = '' + ) { const viewportWidth = document.documentElement.clientWidth const viewportHeight = document.documentElement.clientHeight userInfoCard.user = user userInfoCard.position.x = Math.min(position.x, viewportWidth - 350) userInfoCard.position.y = Math.min(position.y, viewportHeight - 220) + userInfoCard.addSource = addSource + userInfoCard.addSourceExtra = addSourceExtra userInfoCard.show = true }