feat(im): 增加好友申请的逻辑(v1.3:修复各种边界情况,包括静默添加好友)

im
YunaiV 2026-05-04 11:08:03 +08:00
parent 89ee5d51ea
commit b6ca1187b1
5 changed files with 78 additions and 48 deletions

View File

@ -261,20 +261,20 @@ function backToSearch() {
targetUser.value = null
}
/** 提交好友申请 */
/** 提交好友申请:返回 requestId 走「等待验证」;返回 null 表示后端命中「单向好友静默重启」分支,已直接成为好友 */
async function handleSubmitApply() {
if (!targetUser.value) {
return
}
submitting.value = true
try {
await friendStore.applyFriend({
const requestId = await friendStore.applyFriend({
toUserId: targetUser.value.id,
applyContent: applyContent.value.trim() || undefined,
displayName: displayName.value.trim() || undefined,
addSource: props.addSource
})
message.success('申请已发送,等待对方验证')
message.success(requestId ? '申请已发送,等待对方验证' : '已添加为好友')
visible.value = false
} finally {
submitting.value = false

View File

@ -70,7 +70,7 @@
"
@click="handleRowClick"
>
<span class="flex-shrink-0 w-14 text-[var(--el-text-color-secondary)]">备注</span>
<span class="flex-shrink-0 w-16 whitespace-nowrap text-[var(--el-text-color-secondary)]">备注</span>
<el-input
v-if="editingRemark"
ref="remarkInputRef"
@ -103,6 +103,23 @@
/>
</template>
</div>
<!-- ==================== 更多信息来源 / 添加时间对齐微信朋友资料分块 ==================== -->
<template v-if="friendInfo?.addSource || friendInfo?.addTime">
<div class="my-4 h-px bg-[var(--el-border-color-lighter)]"></div>
<div v-if="friendInfo?.addSource" class="flex gap-5 items-center px-1.5 py-1.5 text-sm">
<span class="flex-shrink-0 w-16 whitespace-nowrap text-[var(--el-text-color-secondary)]">来源</span>
<span class="flex-1 min-w-0 truncate text-[var(--el-text-color-primary)]">
{{ getDictLabel(DICT_TYPE.IM_FRIEND_ADD_SOURCE, friendInfo.addSource) }}
</span>
</div>
<div v-if="friendInfo?.addTime" class="flex gap-5 items-center px-1.5 py-1.5 text-sm">
<span class="flex-shrink-0 w-16 whitespace-nowrap text-[var(--el-text-color-secondary)]">添加时间</span>
<span class="flex-1 min-w-0 truncate text-[var(--el-text-color-primary)]">
{{ formatDate(new Date(friendInfo.addTime), 'YYYY-MM-DD') }}
</span>
</div>
</template>
</template>
<!-- 动作区好友 = 3 图标陌生人 = 加为好友按钮自己 = disabledreadonly 不渲染 -->
@ -161,6 +178,8 @@ 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 { DICT_TYPE, getDictLabel } from '@/utils/dict'
import { formatDate } from '@/utils/formatTime'
import type { User } from '../../types'
defineOptions({ name: 'ImUserInfo' })
@ -218,6 +237,11 @@ const deptText = computed(() => full.value?.deptName || '-')
const genderIcon = computed(() => getGenderIcon(full.value?.sex))
const genderColor = computed(() => getGenderColor(full.value?.sex))
/** 好友关系记录:来源 / 添加时间从这里取(仅 friend 态下才有意义) */
const friendInfo = computed(() =>
props.user?.id ? friendStore.getFriend(props.user.id) : undefined
)
/** 备注内联编辑editingRemark 控制输入态user 切换时由下面的 watch 复位避免脏态泄漏 */
const editingRemark = ref(false)
const remarkInput = ref('')

View File

@ -1,12 +1,21 @@
<template>
<!--
新的朋友详情面板
- 头像 + 昵称
- 申请理由块
- 来源行
- 操作按钮我发起 / 别人加我 + 未处理 / 同意 / 拒绝状态切换
- 已通过 直接走 UserInfo 好友详情跟通讯录里点开好友的体验完全一致
- 未处理 / 已拒绝 申请态面板头像 + 申请/拒绝对话气泡 + 来源 + 操作按钮
-->
<div class="flex flex-col items-center px-6 pt-12">
<div v-if="agreed" class="flex justify-center pt-12 px-6">
<div class="w-full max-w-[320px]">
<UserInfo
:user="peerUser"
:display-name="friendStore.getFriend(peerUserId)?.displayName || ''"
relation="friend"
@chat="emit('chat', peerUserId)"
/>
</div>
</div>
<div v-else class="flex flex-col items-center px-6 pt-12">
<UserAvatar
:id="peerUserId"
:url="peerAvatar"
@ -18,32 +27,32 @@
{{ peerNickname }}
</div>
<!-- 申请理由块 -->
<!-- 申请理由块仿微信灰底气泡按申请方身份前缀长文本 break-words 折行 -->
<div
v-if="request.applyContent"
class="w-full max-w-[420px] mt-6 px-3.5 py-3 rounded-md bg-[var(--el-fill-color-light)] text-13px text-[var(--el-text-color-primary)]"
class="w-full max-w-[420px] mt-6 px-3.5 py-3 rounded-md bg-[var(--el-fill-color-light)] text-13px text-[var(--el-text-color-primary)] break-words"
>
<span v-if="iSentIt">:</span>
<span class="text-[var(--el-text-color-secondary)]">
{{ iSentIt ? '我' : peerNickname }}:
</span>
<span class="ml-1">{{ request.applyContent }}</span>
</div>
<!-- 来源行 -->
<!-- 来源行label value 对齐微信来源 -->
<div
v-if="addSourceLabel"
v-if="request.addSource"
class="w-full max-w-[420px] mt-3 flex items-center text-13px text-[var(--el-text-color-secondary)]"
>
<span class="w-12 flex-shrink-0">来源</span>
<span class="text-[var(--el-text-color-primary)]">{{ addSourceLabel }}</span>
<span class="w-16 flex-shrink-0 whitespace-nowrap">来源</span>
<span class="text-[var(--el-text-color-primary)]">
{{ getDictLabel(DICT_TYPE.IM_FRIEND_ADD_SOURCE, request.addSource) }}
</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 -->
<!-- 拒绝理由label value 长文本 break-words 自动折行 -->
<div
v-if="request.handleResult === ImFriendRequestHandleResult.REFUSED && request.handleContent"
v-if="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="w-16 flex-shrink-0 whitespace-nowrap">拒绝理由</span>
<span class="flex-1 min-w-0 break-words text-[var(--el-text-color-primary)]">
{{ request.handleContent }}
</span>
@ -63,18 +72,8 @@
<el-button @click="handleRefuse" :loading="refusing">拒绝</el-button>
<el-button type="primary" @click="handleAgree" :loading="agreeing"> 同意 </el-button>
</template>
<!-- 已同意发消息 -->
<el-button
v-if="request.handleResult === ImFriendRequestHandleResult.AGREED"
type="primary"
@click="emit('chat', peerUserId)"
>
发消息
</el-button>
<!-- 已拒绝占位禁用按钮 -->
<el-button v-if="request.handleResult === ImFriendRequestHandleResult.REFUSED" disabled>
已拒绝
</el-button>
<el-button v-if="refused" disabled>已拒绝</el-button>
</div>
</div>
</template>
@ -85,11 +84,12 @@ import { useMessage } from '@/hooks/web/useMessage'
import { ElMessageBox } from 'element-plus'
import UserAvatar from '../../components/user/UserAvatar.vue'
import UserInfo from '../../components/user/UserInfo.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'
import type { FriendRequest, User } from '../../types'
defineOptions({ name: 'ImContactFriendRequestDetail' })
@ -109,6 +109,16 @@ const currentUserId = Number(getCurrentUserId() || 0)
/** 是不是我发起的fromUserId === me */
const iSentIt = computed(() => props.request.fromUserId === currentUserId)
/** 是否「已拒绝」态模板里多处用到computed 一次省得到处写枚举比对 */
const refused = computed(
() => props.request.handleResult === ImFriendRequestHandleResult.REFUSED
)
/** 是否「已通过」态:转走 UserInfo 好友详情入口 */
const agreed = computed(
() => props.request.handleResult === ImFriendRequestHandleResult.AGREED
)
/** 对端的用户编号 / 昵称 / 头像 */
const peerUserId = computed(() =>
iSentIt.value ? props.request.toUserId : props.request.fromUserId
@ -122,13 +132,12 @@ const peerAvatar = computed(() =>
iSentIt.value ? props.request.toAvatar : props.request.fromAvatar
)
/** 添加来源文案:走字典,对齐后端 ImFriendAddSourceEnum */
// TODO @AI html
const addSourceLabel = computed(() =>
props.request.addSource
? getDictLabel(DICT_TYPE.IM_FRIEND_ADD_SOURCE, props.request.addSource)
: ''
)
/** 透给 UserInfo 的最小用户信息UserInfo 内部会按 id 调 getSimpleUser 补齐性别 / 部门 */
const peerUser = computed<User>(() => ({
id: peerUserId.value,
nickname: peerNickname.value,
avatar: peerAvatar.value
}))
const agreeing = ref(false)
const refusing = ref(false)

View File

@ -40,7 +40,7 @@
{{ getPeerNickname(request) }}
</span>
<span class="flex-shrink-0 text-12px text-[var(--el-text-color-secondary)]">
{{ statusLabel(request) }}
{{ getDictLabel(DICT_TYPE.IM_FRIEND_REQUEST_HANDLE_RESULT, request.handleResult) }}
</span>
</div>
<div
@ -108,9 +108,4 @@ function getPeerAvatar(request: FriendRequest): string | undefined {
return request.fromUserId === currentUserId ? request.toAvatar : request.fromAvatar
}
/** 状态文案:走字典,对齐后端 ImFriendRequestHandleResultEnum */
// TODO @AI html vue
function statusLabel(request: FriendRequest): string {
return getDictLabel(DICT_TYPE.IM_FRIEND_REQUEST_HANDLE_RESULT, request.handleResult)
}
</script>

View File

@ -177,6 +177,7 @@ export const useFriendStore = defineStore('imFriendStore', {
request.handleTime = Date.now()
} else {
// 列表过期场景兜底重拉
// TODO @AI是不是只拉这个人避免拉所有
await this.fetchFriendRequests()
}
},
@ -202,6 +203,7 @@ export const useFriendStore = defineStore('imFriendStore', {
/** 按 id 查申请记录 */
findFriendRequest(requestId: number): FriendRequest | undefined {
// TODO @AIrequest
return this.friendRequests.find((r) => r.id === requestId)
},