✨ feat(im): 优化添加好友界面
parent
0c7d1f0df6
commit
d19bdd42d5
|
|
@ -5,6 +5,7 @@ export interface UserVO {
|
|||
username: string
|
||||
nickname: string
|
||||
deptId: number
|
||||
deptName?: string
|
||||
postIds: string[]
|
||||
email: string
|
||||
mobile: string
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 / 女 2,0 / 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>
|
||||
/*
|
||||
非 scoped:el-dropdown 的下拉菜单走 teleport 到 body,scoped 选不到。
|
||||
UserInfoCard 浮层用 z-9998,要把这块抬到更高(默认 --el-popper-z-index ~2050 会被遮罩压住)。
|
||||
-->
|
||||
<style>
|
||||
*/
|
||||
.im-user-info__more-menu {
|
||||
z-index: 10000 !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 / 女 2,0 / 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 ''
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue