✨ feat(im): 增加好友申请的逻辑(v1.3:修复各种边界情况,包括静默添加好友)
parent
89ee5d51ea
commit
b6ca1187b1
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 图标;陌生人 = 加为好友按钮;自己 = disabled;readonly 不渲染 -->
|
||||
|
|
@ -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('')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 @AI:request
|
||||
return this.friendRequests.find((r) => r.id === requestId)
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue