✨ feat(im): 增加好友申请的逻辑(v1.3:修复各种边界情况,包括静默添加好友)
parent
89ee5d51ea
commit
b6ca1187b1
|
|
@ -261,20 +261,20 @@ function backToSearch() {
|
||||||
targetUser.value = null
|
targetUser.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 提交好友申请 */
|
/** 提交好友申请:返回 requestId 走「等待验证」;返回 null 表示后端命中「单向好友静默重启」分支,已直接成为好友 */
|
||||||
async function handleSubmitApply() {
|
async function handleSubmitApply() {
|
||||||
if (!targetUser.value) {
|
if (!targetUser.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
await friendStore.applyFriend({
|
const requestId = await friendStore.applyFriend({
|
||||||
toUserId: targetUser.value.id,
|
toUserId: targetUser.value.id,
|
||||||
applyContent: applyContent.value.trim() || undefined,
|
applyContent: applyContent.value.trim() || undefined,
|
||||||
displayName: displayName.value.trim() || undefined,
|
displayName: displayName.value.trim() || undefined,
|
||||||
addSource: props.addSource
|
addSource: props.addSource
|
||||||
})
|
})
|
||||||
message.success('申请已发送,等待对方验证')
|
message.success(requestId ? '申请已发送,等待对方验证' : '已添加为好友')
|
||||||
visible.value = false
|
visible.value = false
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@
|
||||||
"
|
"
|
||||||
@click="handleRowClick"
|
@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
|
<el-input
|
||||||
v-if="editingRemark"
|
v-if="editingRemark"
|
||||||
ref="remarkInputRef"
|
ref="remarkInputRef"
|
||||||
|
|
@ -103,6 +103,23 @@
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</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>
|
</template>
|
||||||
|
|
||||||
<!-- 动作区:好友 = 3 图标;陌生人 = 加为好友按钮;自己 = disabled;readonly 不渲染 -->
|
<!-- 动作区:好友 = 3 图标;陌生人 = 加为好友按钮;自己 = disabled;readonly 不渲染 -->
|
||||||
|
|
@ -161,6 +178,8 @@ import FriendAddDialog from '../friend/FriendAddDialog.vue'
|
||||||
import { getSimpleUser, type UserVO } from '@/api/system/user'
|
import { getSimpleUser, type UserVO } from '@/api/system/user'
|
||||||
import { useFriendStore } from '../../store/friendStore'
|
import { useFriendStore } from '../../store/friendStore'
|
||||||
import { getGenderColor, getGenderIcon } from '../../../utils/user'
|
import { getGenderColor, getGenderIcon } from '../../../utils/user'
|
||||||
|
import { DICT_TYPE, getDictLabel } from '@/utils/dict'
|
||||||
|
import { formatDate } from '@/utils/formatTime'
|
||||||
import type { User } from '../../types'
|
import type { User } from '../../types'
|
||||||
|
|
||||||
defineOptions({ name: 'ImUserInfo' })
|
defineOptions({ name: 'ImUserInfo' })
|
||||||
|
|
@ -218,6 +237,11 @@ const deptText = computed(() => full.value?.deptName || '-')
|
||||||
const genderIcon = computed(() => getGenderIcon(full.value?.sex))
|
const genderIcon = computed(() => getGenderIcon(full.value?.sex))
|
||||||
const genderColor = computed(() => getGenderColor(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 复位避免脏态泄漏 */
|
/** 备注内联编辑:editingRemark 控制输入态;user 切换时由下面的 watch 复位避免脏态泄漏 */
|
||||||
const editingRemark = ref(false)
|
const editingRemark = ref(false)
|
||||||
const remarkInput = ref('')
|
const remarkInput = ref('')
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,21 @@
|
||||||
<template>
|
<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
|
<UserAvatar
|
||||||
:id="peerUserId"
|
:id="peerUserId"
|
||||||
:url="peerAvatar"
|
:url="peerAvatar"
|
||||||
|
|
@ -18,32 +27,32 @@
|
||||||
{{ peerNickname }}
|
{{ peerNickname }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 申请理由块 -->
|
<!-- 申请理由块:仿微信灰底气泡,按申请方身份前缀;长文本 break-words 折行 -->
|
||||||
<div
|
<div
|
||||||
v-if="request.applyContent"
|
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>
|
<span class="ml-1">{{ request.applyContent }}</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- 来源行 -->
|
<!-- 来源行:label 左、value 右,对齐微信「来源」行 -->
|
||||||
<div
|
<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)]"
|
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="w-16 flex-shrink-0 whitespace-nowrap">来源</span>
|
||||||
<span class="text-[var(--el-text-color-primary)]">{{ addSourceLabel }}</span>
|
<span class="text-[var(--el-text-color-primary)]">
|
||||||
|
{{ getDictLabel(DICT_TYPE.IM_FRIEND_ADD_SOURCE, request.addSource) }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- 拒绝理由(拒绝状态展示):长文本走 break-words 自动折行,避免横向溢出 -->
|
<!-- 拒绝理由:label 左、value 右;长文本 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
|
<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)]"
|
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)]">
|
<span class="flex-1 min-w-0 break-words text-[var(--el-text-color-primary)]">
|
||||||
{{ request.handleContent }}
|
{{ request.handleContent }}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -63,18 +72,8 @@
|
||||||
<el-button @click="handleRefuse" :loading="refusing">拒绝</el-button>
|
<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>
|
</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 v-if="refused" disabled>已拒绝</el-button>
|
||||||
已拒绝
|
|
||||||
</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -85,11 +84,12 @@ import { useMessage } from '@/hooks/web/useMessage'
|
||||||
import { ElMessageBox } from 'element-plus'
|
import { ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
import UserAvatar from '../../components/user/UserAvatar.vue'
|
import UserAvatar from '../../components/user/UserAvatar.vue'
|
||||||
|
import UserInfo from '../../components/user/UserInfo.vue'
|
||||||
import { useFriendStore } from '../../store/friendStore'
|
import { useFriendStore } from '../../store/friendStore'
|
||||||
import { getCurrentUserId } from '../../../utils/storage'
|
import { getCurrentUserId } from '../../../utils/storage'
|
||||||
import { ImFriendRequestHandleResult } from '../../../utils/constants'
|
import { ImFriendRequestHandleResult } from '../../../utils/constants'
|
||||||
import { DICT_TYPE, getDictLabel } from '@/utils/dict'
|
import { DICT_TYPE, getDictLabel } from '@/utils/dict'
|
||||||
import type { FriendRequest } from '../../types'
|
import type { FriendRequest, User } from '../../types'
|
||||||
|
|
||||||
defineOptions({ name: 'ImContactFriendRequestDetail' })
|
defineOptions({ name: 'ImContactFriendRequestDetail' })
|
||||||
|
|
||||||
|
|
@ -109,6 +109,16 @@ const currentUserId = Number(getCurrentUserId() || 0)
|
||||||
/** 是不是我发起的(fromUserId === me) */
|
/** 是不是我发起的(fromUserId === me) */
|
||||||
const iSentIt = computed(() => props.request.fromUserId === currentUserId)
|
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(() =>
|
const peerUserId = computed(() =>
|
||||||
iSentIt.value ? props.request.toUserId : props.request.fromUserId
|
iSentIt.value ? props.request.toUserId : props.request.fromUserId
|
||||||
|
|
@ -122,13 +132,12 @@ const peerAvatar = computed(() =>
|
||||||
iSentIt.value ? props.request.toAvatar : props.request.fromAvatar
|
iSentIt.value ? props.request.toAvatar : props.request.fromAvatar
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 添加来源文案:走字典,对齐后端 ImFriendAddSourceEnum */
|
/** 透给 UserInfo 的最小用户信息;UserInfo 内部会按 id 调 getSimpleUser 补齐性别 / 部门 */
|
||||||
// TODO @AI:通过 html 里处理掉。不用抽个方法;
|
const peerUser = computed<User>(() => ({
|
||||||
const addSourceLabel = computed(() =>
|
id: peerUserId.value,
|
||||||
props.request.addSource
|
nickname: peerNickname.value,
|
||||||
? getDictLabel(DICT_TYPE.IM_FRIEND_ADD_SOURCE, props.request.addSource)
|
avatar: peerAvatar.value
|
||||||
: ''
|
}))
|
||||||
)
|
|
||||||
|
|
||||||
const agreeing = ref(false)
|
const agreeing = ref(false)
|
||||||
const refusing = ref(false)
|
const refusing = ref(false)
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@
|
||||||
{{ getPeerNickname(request) }}
|
{{ getPeerNickname(request) }}
|
||||||
</span>
|
</span>
|
||||||
<span class="flex-shrink-0 text-12px text-[var(--el-text-color-secondary)]">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|
@ -108,9 +108,4 @@ function getPeerAvatar(request: FriendRequest): string | undefined {
|
||||||
return request.fromUserId === currentUserId ? request.toAvatar : request.fromAvatar
|
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>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,7 @@ export const useFriendStore = defineStore('imFriendStore', {
|
||||||
request.handleTime = Date.now()
|
request.handleTime = Date.now()
|
||||||
} else {
|
} else {
|
||||||
// 列表过期场景兜底重拉
|
// 列表过期场景兜底重拉
|
||||||
|
// TODO @AI:是不是只拉这个人?避免拉所有?
|
||||||
await this.fetchFriendRequests()
|
await this.fetchFriendRequests()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -202,6 +203,7 @@ export const useFriendStore = defineStore('imFriendStore', {
|
||||||
|
|
||||||
/** 按 id 查申请记录 */
|
/** 按 id 查申请记录 */
|
||||||
findFriendRequest(requestId: number): FriendRequest | undefined {
|
findFriendRequest(requestId: number): FriendRequest | undefined {
|
||||||
|
// TODO @AI:request
|
||||||
return this.friendRequests.find((r) => r.id === requestId)
|
return this.friendRequests.find((r) => r.id === requestId)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue