feat(im): 优化添加好友界面

im
YunaiV 2026-04-30 14:53:41 +08:00
parent 0c7d1f0df6
commit d19bdd42d5
4 changed files with 197 additions and 69 deletions

View File

@ -5,6 +5,7 @@ export interface UserVO {
username: string
nickname: string
deptId: number
deptName?: string
postIds: string[]
email: string
mobile: string

View File

@ -0,0 +1,144 @@
<template>
<!--
添加好友对话框
- 按昵称搜索用户列表最多 20
- "添加" 直接发好友申请friendStore 落地后按钮自动切到 "已添加"
-->
<el-dialog v-model="visible" title="添加好友" width="480px" :close-on-click-modal="false">
<el-input
v-model="keyword"
placeholder="输入昵称回车搜索(最多展示 20 条)"
clearable
@keyup.enter="handleSearch"
>
<template #suffix>
<Icon icon="ant-design:search-outlined" class="cursor-pointer" @click="handleSearch" />
</template>
</el-input>
<el-scrollbar v-loading="loading" class="h-[400px] mt-2.5">
<div
v-if="users.length === 0"
class="py-10 text-13px text-center text-[var(--el-text-color-disabled)]"
>
{{ searched ? '没有搜到用户' : '输入关键字后回车开始搜索' }}
</div>
<div
v-for="user in users"
:key="user.id"
v-show="user.id !== currentUserId"
class="flex gap-3 items-center px-2 py-2.5 border-b border-[var(--el-border-color-lighter)]"
>
<UserAvatar
:id="user.id"
:url="user.avatar"
:name="user.nickname"
:size="42"
:clickable="false"
/>
<div class="flex-1 min-w-0 overflow-hidden">
<!-- 昵称 + 性别图标 -->
<div
class="flex items-center gap-1 text-sm font-semibold text-[var(--el-text-color-primary)]"
>
<span class="truncate">{{ user.nickname }}</span>
<Icon
v-if="getGenderIcon(user.sex)"
:icon="getGenderIcon(user.sex)"
:size="14"
:color="getGenderColor(user.sex)"
class="flex-shrink-0"
/>
</div>
<!-- 部门 -->
<div
v-if="user.deptName"
class="mt-0.5 text-xs truncate text-[var(--el-text-color-secondary)]"
>
{{ user.deptName }}
</div>
</div>
<!-- 添加操作 -->
<el-button
v-if="!friendStore.isFriend(user.id)"
type="primary"
size="small"
@click="handleAdd(user)"
>
添加
</el-button>
<el-button v-else size="small" disabled>已添加</el-button>
</div>
</el-scrollbar>
</el-dialog>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import { useMessage } from '@/hooks/web/useMessage'
import UserAvatar from '../user/UserAvatar.vue'
import { useFriendStore } from '../../store/friendStore'
import { getCurrentUserId } from '../../../utils/storage'
import { getGenderColor, getGenderIcon } from '../../../utils/user'
import { getSimpleUserListByNickname, type UserVO } from '@/api/system/user'
defineOptions({ name: 'ImFriendAddDialog' })
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
/** 弹窗显隐:把父侧 v-model 转双向计算 */
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const friendStore = useFriendStore()
const message = useMessage()
const currentUserId = getCurrentUserId() //
const keyword = ref('')
const users = ref<UserVO[]>([])
const searched = ref(false)
const loading = ref(false)
// /
watch(visible, (open) => {
if (open) {
keyword.value = ''
users.value = []
searched.value = false
}
})
/** 按昵称搜索用户:空关键字直接清空结果 */
async function handleSearch() {
searched.value = true
if (!keyword.value.trim()) {
users.value = []
return
}
loading.value = true
try {
users.value = (await getSimpleUserListByNickname(keyword.value.trim())) || []
} finally {
loading.value = false
}
}
/** 发起好友申请:成功后 friendStore 已落地,按钮自动切到 "已添加" */
async function handleAdd(user: UserVO) {
await friendStore.addFriend(user.id, {
nickname: user.nickname,
avatar: user.avatar
})
message.success('已添加好友')
}
</script>

View File

@ -41,19 +41,11 @@
</div>
<!-- 右上 "..." 菜单 friend 态展示菜单项目前只有"删除联系人"其它 WeChat 选项业务上未支持 -->
<div v-if="relation === 'friend'" class="flex-shrink-0">
<el-dropdown
trigger="click"
placement="bottom-end"
popper-class="im-user-info__more-menu"
>
<el-dropdown trigger="click" placement="bottom-end" popper-class="im-user-info__more-menu">
<div
class="flex items-center justify-center w-7 h-7 rounded cursor-pointer hover:bg-[var(--el-fill-color-light)]"
>
<Icon
icon="ep:more-filled"
:size="18"
class="text-[var(--el-text-color-secondary)]"
/>
<Icon icon="ep:more-filled" :size="18" class="text-[var(--el-text-color-secondary)]" />
</div>
<template #dropdown>
<el-dropdown-menu>
@ -76,7 +68,7 @@
? 'group cursor-pointer rounded transition-colors hover:bg-[var(--el-fill-color-lighter)]'
: ''
"
@click="onRowClick"
@click="handleRowClick"
>
<span class="flex-shrink-0 w-14 text-[var(--el-text-color-secondary)]">备注</span>
<el-input
@ -127,14 +119,14 @@
</div>
<div
class="flex flex-col gap-1.5 items-center cursor-pointer text-13px text-[var(--el-color-primary)] transition-opacity hover:opacity-75"
@click="onComingSoon('语音聊天')"
@click="handleComingSoon('语音聊天')"
>
<Icon icon="ant-design:phone-outlined" :size="22" />
<span>语音聊天</span>
</div>
<div
class="flex flex-col gap-1.5 items-center cursor-pointer text-13px text-[var(--el-color-primary)] transition-opacity hover:opacity-75"
@click="onComingSoon('视频聊天')"
@click="handleComingSoon('视频聊天')"
>
<Icon icon="ant-design:video-camera-outlined" :size="22" />
<span>视频聊天</span>
@ -159,6 +151,7 @@ import { useMessage } from '@/hooks/web/useMessage'
import UserAvatar from './UserAvatar.vue'
import { getSimpleUser } from '@/api/system/user'
import { useFriendStore } from '../../store/friendStore'
import { getGenderColor, getGenderIcon } from '../../../utils/user'
import type { User } from '../../types'
defineOptions({ name: 'ImUserInfo' })
@ -208,29 +201,8 @@ const headerName = computed(() => props.displayName || full.value?.nickname || '
const deptText = computed(() => full.value?.deptName || '-')
/** 性别图标:男 1 / 女 20 / null / undefined 一律不展示 */
const genderIcon = computed(() => {
// TODO @AI:
const sex = full.value?.sex
if (sex === 1) {
return 'mdi:human-male'
}
if (sex === 2) {
return 'mdi:human-female'
}
return ''
})
const genderColor = computed(() => {
const sex = full.value?.sex
if (sex === 1) {
return '#5b97f5'
}
if (sex === 2) {
return '#f56c92'
}
return ''
})
const genderIcon = computed(() => getGenderIcon(full.value?.sex))
const genderColor = computed(() => getGenderColor(full.value?.sex))
/** 备注内联编辑editingRemark 控制输入态user 切换时由下面的 watch 复位避免脏态泄漏 */
const editingRemark = ref(false)
@ -251,33 +223,25 @@ watch(
if (!id) {
return
}
try {
const data = (await getSimpleUser(id)) as User
if (props.user?.id !== id) {
return
}
full.value = { ...props.user, ...data }
} catch (e) {
// TODO @AI id
console.warn('[IM] 获取用户详情失败', e)
const data = (await getSimpleUser(id)) as User
if (props.user?.id !== id) {
return
}
full.value = { ...props.user, ...data }
},
{ immediate: true }
)
// TODO @AI使 handleXXX
// TODO @AI await
// TODO @AI
function onRowClick() {
/** 备注行点击:进编辑态 + 把当前备注灌进输入框,下一帧把焦点 / 全选交给 el-input */
async function handleRowClick() {
if (editingRemark.value) {
return
}
remarkInput.value = props.displayName || ''
editingRemark.value = true
void nextTick(() => {
remarkInputRef.value?.focus()
remarkInputRef.value?.select()
})
await nextTick()
remarkInputRef.value?.focus()
remarkInputRef.value?.select()
}
/**
@ -335,29 +299,25 @@ async function handleDeleteFriend() {
return
}
const target = props.user
// message.confirm reject return
try {
await message.confirm(`确定删除好友「${target.nickname || ''}」吗?`, '删除联系人')
} catch {
return
}
//
await message.confirm(`确定删除好友「${target.nickname || ''}」吗?`, '删除联系人')
//
await friendStore.deleteFriend(target.id)
message.success('已删除好友')
emit('deleted', target)
}
/** 占位提示:语音 / 视频聊天能力尚未接入,先以"开发中"友好提示 */
function onComingSoon(featureName: string) {
function handleComingSoon(featureName: string) {
message.info(`${featureName} 功能开发中`)
}
</script>
<!--
TODO @AI这个是不是注释到 style
<style>
/*
scopedel-dropdown 的下拉菜单走 teleport bodyscoped 选不到
UserInfoCard 浮层用 z-9998要把这块抬到更高默认 --el-popper-z-index ~2050 会被遮罩压住
-->
<style>
*/
.im-user-info__more-menu {
z-index: 10000 !important;
}

View File

@ -1,11 +1,12 @@
// ====================================================================
// IM 用户展示 utility
// IM 用户展示 utility
// ====================================================================
// 职责:统一回答"某个用户在 UI 上应该叫什么名字"。拆两层:
// 1. 纯派生getFriendDisplayName / getMemberDisplayName / getGroupDisplayName输入 friend / member / group 对象,不查 store给 store 内部 / 各种组装点复用,避免逻辑散落
// 2. 上下文感知getSenderDisplayName / getSenderRealNickname / tryGetSenderDisplayName渲染时按 conversation 上下文实时查 friendStore / groupStore / userStore让备注 / 群昵称 / 真实昵称变更后所有历史消息立即响应式刷新
// 职责:统一回答"某个用户在 UI 上应该如何展示",包含:
// 1. 显示名getFriendDisplayName / getMemberDisplayName / getGroupDisplayName / getSenderDisplayName 等)
// 2. 上下文感知名tryGetSenderDisplayName / getSenderRealNickname渲染时按 conversation 上下文实时查 friendStore / groupStore / userStore让备注 / 群昵称 / 真实昵称变更后所有历史消息立即响应式刷新
// 3. 性别图标 / 颜色getGenderIcon / getGenderColor男蓝、女粉未知不展示所有 UserInfo 卡片 / 列表行共用
//
// 命名约定:函数一律使用 displayName与 friend.displayName / member.displayUserName 字段对齐
// 命名约定:显示名相关函数一律使用 displayName与 friend.displayName / member.displayUserName 字段对齐
// ====================================================================
import { useUserStore } from '@/store/modules/user'
@ -148,3 +149,25 @@ export function getSenderRealNickname(
}
return String(senderId)
}
/** 性别图标:男 1 / 女 20 / null / undefined 一律不展示,对齐微信留白 */
export function getGenderIcon(sex?: number): string {
if (sex === 1) {
return 'mdi:human-male'
}
if (sex === 2) {
return 'mdi:human-female'
}
return ''
}
/** 性别图标主题色:男蓝、女粉 */
export function getGenderColor(sex?: number): string {
if (sex === 1) {
return '#5b97f5'
}
if (sex === 2) {
return '#f56c92'
}
return ''
}