✨ feat(im): 增加好友申请的逻辑(v1.1:增加各种 code review 注释)
parent
f86cd30af4
commit
1469d8bb3d
|
|
@ -1,10 +1,12 @@
|
|||
<template>
|
||||
<!--
|
||||
添加好友对话框
|
||||
- 按昵称搜索用户列表(最多 20 条)
|
||||
- 点 "添加" 直接发好友申请,friendStore 落地后按钮自动切到 "已添加"
|
||||
添加好友对话框(双层流程)
|
||||
- 第一层 search:按昵称搜索用户列表
|
||||
- 第二层 apply:选中用户后展开「申请添加朋友」表单(申请理由 + 备注)
|
||||
-->
|
||||
<el-dialog v-model="visible" title="添加好友" width="480px" :close-on-click-modal="false">
|
||||
<el-dialog v-model="visible" :title="dialogTitle" width="480px" :close-on-click-modal="false">
|
||||
<!-- 第一层:搜索 + 用户列表 -->
|
||||
<template v-if="step === 'search'">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="输入昵称回车搜索(最多展示 20 条)"
|
||||
|
|
@ -58,18 +60,70 @@
|
|||
{{ user.deptName }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- 添加操作 -->
|
||||
<!-- 已是好友显示「已添加」;否则显示「添加」(点击进入 apply 步骤) -->
|
||||
<el-button
|
||||
v-if="!friendStore.isFriend(user.id)"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleAdd(user)"
|
||||
@click="enterApply(user)"
|
||||
>
|
||||
添加
|
||||
</el-button>
|
||||
<el-button v-else size="small" disabled>已添加</el-button>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</template>
|
||||
|
||||
<!-- 第二层:申请表单(对齐微信「申请添加朋友」) -->
|
||||
<template v-if="step === 'apply' && targetUser">
|
||||
<!-- 选中的用户卡片 -->
|
||||
<div
|
||||
class="flex gap-3 items-center px-2 py-3 mb-4 rounded-md bg-[var(--el-fill-color-light)]"
|
||||
>
|
||||
<UserAvatar
|
||||
:id="targetUser.id"
|
||||
:url="targetUser.avatar"
|
||||
:name="targetUser.nickname"
|
||||
:size="42"
|
||||
:clickable="false"
|
||||
/>
|
||||
<div class="flex-1 min-w-0 overflow-hidden">
|
||||
<div class="text-sm font-semibold text-[var(--el-text-color-primary)] truncate">
|
||||
{{ targetUser.nickname }}
|
||||
</div>
|
||||
<div
|
||||
v-if="targetUser.deptName"
|
||||
class="mt-0.5 text-xs truncate text-[var(--el-text-color-secondary)]"
|
||||
>
|
||||
{{ targetUser.deptName }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-13px text-[var(--el-text-color-secondary)] mb-1.5">发送添加朋友申请</div>
|
||||
<el-input
|
||||
v-model="applyContent"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:maxlength="255"
|
||||
show-word-limit
|
||||
placeholder="请填写申请理由"
|
||||
/>
|
||||
|
||||
<div class="text-13px text-[var(--el-text-color-secondary)] mt-3 mb-1.5">备注</div>
|
||||
<el-input
|
||||
v-model="displayName"
|
||||
:maxlength="16"
|
||||
placeholder="给对方起个备注(仅自己可见,可不填)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 仅在 apply 步骤显示 footer 操作按钮(slot 必须是 el-dialog 直接子节点) -->
|
||||
<template v-if="step === 'apply'" #footer>
|
||||
<!-- 预填模式无搜索步骤,「取消」直接关闭弹窗 -->
|
||||
<el-button @click="presetMode ? (visible = false) : backToSearch()">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmitApply"> 确定 </el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
|
|
@ -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<{
|
||||
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,14 +177,59 @@ const users = ref<UserVO[]>([])
|
|||
const searched = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
// 每次重新打开都把搜索态清空,避免上次的关键字 / 结果泄漏到下次
|
||||
/** 当前步骤:search=搜索列表;apply=申请表单 */
|
||||
const step = ref<'search' | 'apply'>('search')
|
||||
/** 申请目标用户 */
|
||||
const targetUser = ref<UserVO | null>(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) {
|
||||
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() {
|
||||
|
|
@ -133,12 +246,38 @@ async function handleSearch() {
|
|||
}
|
||||
}
|
||||
|
||||
/** 发起好友申请:成功后 friendStore 已落地,按钮自动切到 "已添加" */
|
||||
async function handleAdd(user: UserVO) {
|
||||
await friendStore.addFriend(user.id, {
|
||||
nickname: user.nickname,
|
||||
avatar: user.avatar
|
||||
/** 进入申请步骤:预填申请理由「我是 ${当前用户昵称}」(对齐微信交互) */
|
||||
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('已添加好友')
|
||||
message.success('申请已发送,等待对方验证')
|
||||
visible.value = false
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@
|
|||
:url="member.avatar"
|
||||
:clickable="clickable"
|
||||
:id="member.userId"
|
||||
:add-source="2"
|
||||
:add-source-extra="groupName"
|
||||
/>
|
||||
<div
|
||||
class="flex-1 h-full pl-2.5 overflow-hidden text-sm text-left truncate text-[var(--el-text-color-regular)]"
|
||||
|
|
@ -47,11 +49,13 @@ const props = withDefaults(
|
|||
height?: number // 行高(px),影响头像大小
|
||||
active?: boolean // 选中态(@候选键盘高亮等)
|
||||
clickable?: boolean // 头像点击是否弹 UserInfoCard;@候选场景通常禁用(避免嵌套交互)
|
||||
groupName?: string // 群名:加好友时拼「我是 'XX 群' 的 YY」话术,落库 add_source=GROUP
|
||||
}>(),
|
||||
{
|
||||
height: 50,
|
||||
active: false,
|
||||
clickable: false
|
||||
clickable: false,
|
||||
groupName: ''
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -8,12 +8,15 @@
|
|||
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-extra="groupName"
|
||||
/>
|
||||
<div
|
||||
class="w-full mt-1 overflow-hidden text-12px leading-[18px] text-center truncate text-[var(--el-text-color-regular)]"
|
||||
|
|
@ -35,10 +38,12 @@ withDefaults(
|
|||
member: GroupMemberLite
|
||||
clickable?: boolean // 头像点击是否弹 UserInfoCard;选择器宫格里需要保持关闭,避免和勾选交互冲突
|
||||
size?: number // 头像像素大小;默认 38(兼容选择器右侧已选区),群信息抽屉传 50 对齐微信 PC
|
||||
groupName?: string // 群名:加好友时拼「我是 'XX 群' 的 YY」话术,落库 add_source=GROUP
|
||||
}>(),
|
||||
{
|
||||
clickable: false,
|
||||
size: 38
|
||||
size: 38,
|
||||
groupName: ''
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -139,6 +139,14 @@
|
|||
<el-button type="primary" @click="handleAddFriend">加为好友</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 加好友弹窗:携带预填用户跳过搜索步骤,直接进申请表单(理由按 addSource 区分话术) -->
|
||||
<FriendAddDialog
|
||||
v-model="addFriendVisible"
|
||||
:preset-user="presetUserForAdd"
|
||||
:add-source="addSource"
|
||||
:add-source-extra="addSourceExtra"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -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<UserVO | null>(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(内部级联清会话)→ 通知父级关浮层 / 清选中 */
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,171 @@
|
|||
<template>
|
||||
<!--
|
||||
新的朋友详情面板
|
||||
- 头像 + 昵称
|
||||
- 申请理由块
|
||||
- 来源行
|
||||
- 操作按钮:按「我发起 / 别人加我」 + 「未处理 / 同意 / 拒绝」状态切换
|
||||
-->
|
||||
<div class="flex flex-col items-center px-6 pt-12">
|
||||
<UserAvatar
|
||||
:id="peerUserId"
|
||||
:url="peerAvatar"
|
||||
:name="peerNickname"
|
||||
:size="64"
|
||||
:clickable="false"
|
||||
/>
|
||||
<div class="mt-3 text-base font-semibold text-[var(--el-text-color-primary)]">
|
||||
{{ peerNickname }}
|
||||
</div>
|
||||
|
||||
<!-- 申请理由块 -->
|
||||
<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)]"
|
||||
>
|
||||
<span v-if="iSentIt">我:</span>
|
||||
<span class="ml-1">{{ request.applyContent }}</span>
|
||||
</div>
|
||||
<!-- 来源行 -->
|
||||
<div
|
||||
v-if="addSourceLabel"
|
||||
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>
|
||||
</div>
|
||||
<!-- 拒绝理由(拒绝状态展示) -->
|
||||
<!-- TODO @AI:会折行,拒绝理由; -->
|
||||
<div
|
||||
v-if="request.handleResult === 2 && 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>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="w-full max-w-[420px] mt-8 flex justify-center">
|
||||
<!-- 我发起 + 等待中:禁用「等待对方验证」 -->
|
||||
<el-button v-if="iSentIt && request.handleResult === 0" disabled>
|
||||
等待对方验证
|
||||
</el-button>
|
||||
<!-- 别人加我 + 等待中:同意 / 拒绝 -->
|
||||
<template v-if="!iSentIt && request.handleResult === 0">
|
||||
<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 === 1"
|
||||
type="primary"
|
||||
@click="emit('chat', peerUserId)"
|
||||
>
|
||||
发消息
|
||||
</el-button>
|
||||
<!-- 已拒绝:占位禁用按钮 -->
|
||||
<el-button v-if="request.handleResult === 2" disabled>已拒绝</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useMessage } from '@/hooks/web/useMessage'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
|
||||
import UserAvatar from '../../components/user/UserAvatar.vue'
|
||||
import { useFriendStore } from '../../store/friendStore'
|
||||
import { getCurrentUserId } from '../../../utils/storage'
|
||||
import type { FriendRequest } from '../../types'
|
||||
|
||||
defineOptions({ name: 'ImContactFriendRequestDetail' })
|
||||
|
||||
const props = defineProps<{
|
||||
request: FriendRequest
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
chat: [peerUserId: number]
|
||||
}>()
|
||||
|
||||
const friendStore = useFriendStore()
|
||||
const message = useMessage()
|
||||
|
||||
const currentUserId = Number(getCurrentUserId() || 0)
|
||||
|
||||
/** 是不是我发起的(fromUserId === me) */
|
||||
const iSentIt = computed(() => props.request.fromUserId === currentUserId)
|
||||
|
||||
/** 对端的用户编号 / 昵称 / 头像 */
|
||||
const peerUserId = computed(() =>
|
||||
iSentIt.value ? props.request.toUserId : props.request.fromUserId
|
||||
)
|
||||
const peerNickname = computed(() =>
|
||||
iSentIt.value
|
||||
? props.request.toNickname || String(props.request.toUserId)
|
||||
: props.request.fromNickname || String(props.request.fromUserId)
|
||||
)
|
||||
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 ''
|
||||
}
|
||||
})
|
||||
|
||||
const agreeing = ref(false)
|
||||
const refusing = ref(false)
|
||||
|
||||
/** 同意申请 */
|
||||
async function handleAgree() {
|
||||
agreeing.value = true
|
||||
try {
|
||||
await friendStore.agreeFriendRequest(props.request.id)
|
||||
message.success('已同意好友申请')
|
||||
} finally {
|
||||
agreeing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 拒绝申请 */
|
||||
async function handleRefuse() {
|
||||
// TODO @AI:增加注释,参考 system user index.vue 的风格;
|
||||
let handleContent: string | undefined
|
||||
try {
|
||||
const result = await ElMessageBox.prompt('可填写拒绝理由(选填)', '拒绝好友申请', {
|
||||
confirmButtonText: '拒绝',
|
||||
cancelButtonText: '取消',
|
||||
inputType: 'textarea',
|
||||
inputValue: '',
|
||||
inputPlaceholder: '不填则不告知对方原因',
|
||||
inputValidator: (value: string) => (value || '').length <= 255 || '最多 255 个字符'
|
||||
})
|
||||
handleContent = (result as { value?: string }).value || undefined
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
refusing.value = true
|
||||
try {
|
||||
await friendStore.refuseFriendRequest(props.request.id, handleContent)
|
||||
message.success('已拒绝好友申请')
|
||||
} finally {
|
||||
refusing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
<template>
|
||||
<!--
|
||||
通讯录 - 新的朋友分组
|
||||
- 自治:折叠状态由组件内 ref 管理,默认展开
|
||||
- 列表项展示:头像 + 昵称 + 申请理由 + 状态标签(等待验证 / 已添加 / 已拒绝)
|
||||
- 选中态由父级 activeId 透传;点击触发 select 事件
|
||||
-->
|
||||
<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"
|
||||
>
|
||||
<Icon :icon="expanded ? 'ep:caret-bottom' : 'ep:caret-right'" :size="14" />
|
||||
<span class="flex-1">新的朋友</span>
|
||||
<!-- 红点:未处理且别人加我的 -->
|
||||
<el-badge v-if="unhandledCount > 0" :value="unhandledCount" :max="99" class="mr-2" />
|
||||
<span class="text-sm text-[var(--el-text-color-secondary)]">{{ requests.length }}</span>
|
||||
</div>
|
||||
<div v-show="expanded">
|
||||
<div
|
||||
v-for="request in requests"
|
||||
:key="request.id"
|
||||
class="flex gap-3 items-start px-3.5 py-2.5 cursor-pointer transition-colors hover:bg-[var(--el-fill-color-light)]"
|
||||
:class="{
|
||||
'bg-[var(--el-fill-color)]': activeId === request.id
|
||||
}"
|
||||
@click="emit('select', request)"
|
||||
>
|
||||
<UserAvatar
|
||||
:id="getPeerUserId(request)"
|
||||
:url="getPeerAvatar(request)"
|
||||
:name="getPeerNickname(request)"
|
||||
:size="36"
|
||||
:clickable="false"
|
||||
/>
|
||||
<div class="flex-1 min-w-0 overflow-hidden">
|
||||
<div class="flex justify-between gap-2 items-center">
|
||||
<span class="flex-1 text-sm font-medium truncate text-[var(--el-text-color-primary)]">
|
||||
{{ getPeerNickname(request) }}
|
||||
</span>
|
||||
<span class="flex-shrink-0 text-12px text-[var(--el-text-color-secondary)]">
|
||||
{{ statusLabel(request) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="request.applyContent"
|
||||
class="mt-0.5 text-xs truncate text-[var(--el-text-color-secondary)]"
|
||||
>
|
||||
{{ request.applyContent }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="requests.length === 0"
|
||||
class="py-3 text-12px text-center text-[var(--el-text-color-disabled)]"
|
||||
>
|
||||
暂无新的朋友
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
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 type { FriendRequest } from '../../types'
|
||||
|
||||
defineOptions({ name: 'ImContactFriendRequestList' })
|
||||
|
||||
const props = defineProps<{
|
||||
requests: FriendRequest[]
|
||||
activeId?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [request: FriendRequest]
|
||||
}>()
|
||||
|
||||
const expanded = ref(true)
|
||||
// TODO @AI:是不是不用 Number 处理;
|
||||
const currentUserId = Number(getCurrentUserId() || 0)
|
||||
|
||||
/** 未处理 + 别人加我的(接收方=我)才进红点;我发起的不进 */
|
||||
const unhandledCount = computed(
|
||||
() => props.requests.filter((r) => r.handleResult === 0 && r.toUserId === currentUserId).length
|
||||
)
|
||||
|
||||
/** 列表项展示对端:fromUserId == 我 → 对端 = toUser;否则对端 = fromUser */
|
||||
function getPeerUserId(request: FriendRequest): number {
|
||||
return request.fromUserId === currentUserId ? request.toUserId : request.fromUserId
|
||||
}
|
||||
|
||||
// TODO @AI:注释
|
||||
function getPeerNickname(request: FriendRequest): string {
|
||||
return request.fromUserId === currentUserId
|
||||
? request.toNickname || String(request.toUserId)
|
||||
: request.fromNickname || String(request.fromUserId)
|
||||
}
|
||||
|
||||
// TODO @AI:注释
|
||||
function getPeerAvatar(request: FriendRequest): string | undefined {
|
||||
return request.fromUserId === currentUserId ? request.toAvatar : request.fromAvatar
|
||||
}
|
||||
|
||||
/** 状态文案:未处理 / 同意 / 拒绝 */
|
||||
// TODO @AI:字典添加下;这里简化掉;
|
||||
function statusLabel(request: FriendRequest): string {
|
||||
if (request.handleResult === 1) {
|
||||
return '已添加'
|
||||
}
|
||||
if (request.handleResult === 2) {
|
||||
return '已拒绝'
|
||||
}
|
||||
return '等待验证'
|
||||
}
|
||||
</script>
|
||||
|
|
@ -21,7 +21,13 @@
|
|||
<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">
|
||||
<GroupMemberGrid v-for="member in members" :key="member.userId" :member="member" />
|
||||
<!-- TODO @AI:是不是传入 groupname 更合适?不应该给别人看到,我自己自定义的。 -->
|
||||
<GroupMemberGrid
|
||||
v-for="member in members"
|
||||
:key="member.userId"
|
||||
:member="member"
|
||||
:group-name="group.showGroupName || group.name"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<el-button type="primary" @click="emit('chat', group)">进入群聊</el-button>
|
||||
|
|
|
|||
|
|
@ -18,8 +18,13 @@
|
|||
</el-input>
|
||||
</div>
|
||||
|
||||
<!-- 列表主体:拆 GroupList / FriendList 两个子组件,各自管理折叠 + 过滤;本页只透传选中态 -->
|
||||
<!-- 列表主体:拆 FriendRequestList / GroupList / FriendList 三个子组件,各自管理折叠 + 过滤;本页只透传选中态 -->
|
||||
<el-scrollbar class="flex-1">
|
||||
<FriendRequestList
|
||||
:requests="friendRequests"
|
||||
:active-id="selection?.type === 'request' ? selection.request.id : undefined"
|
||||
@select="handleSelectRequest"
|
||||
/>
|
||||
<GroupList
|
||||
:groups="groups"
|
||||
:keyword="keyword"
|
||||
|
|
@ -70,6 +75,12 @@
|
|||
:group="selection.group"
|
||||
@chat="handleChatGroup"
|
||||
/>
|
||||
<!-- 新的朋友 - 申请详情 -->
|
||||
<FriendRequestDetail
|
||||
v-else-if="selection.type === 'request'"
|
||||
:request="currentRequest"
|
||||
@chat="handleChatPeer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -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<Selection | null>(null)
|
||||
const keyword = ref('')
|
||||
|
||||
/** 选中申请详情:详情用 store 里的最新副本(同意 / 拒绝后状态会变) */
|
||||
const currentRequest = computed<FriendRequest>(() => {
|
||||
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<FriendRequest[]>(() => friendStore.friendRequests)
|
||||
|
||||
/** 好友列表的展示快照:附带后端算好的拼音,给 FriendList 做字母分桶 / 拼音搜索 */
|
||||
const friends = computed<FriendLite[]>(() =>
|
||||
friendStore.getActiveFriends.map((friend: Friend) => ({
|
||||
|
|
@ -145,7 +173,11 @@ const friendUser = computed<User | null>(() => {
|
|||
})
|
||||
|
||||
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 同步备注 + 免打扰,避免新建会话用过期数据
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
:member="member"
|
||||
:size="50"
|
||||
clickable
|
||||
:group-name="group.name"
|
||||
/>
|
||||
|
||||
<!-- 添加(任何成员都能邀请) -->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue