admin-vue3/src/views/im/home/components/user/UserInfo.vue

349 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<!--
用户名片自包含组件浮层 / 行内通用
- UserInfoCard浮层 contact/index.vue行内共用UserInfoCard 把它放进 teleport 浮层contact 直接 mount 到右栏
- 关系态由 relation prop 决定friend / stranger / self / readonly对应右上 "..." 菜单 + 底部动作区两块都内化在本组件
- 备注 / 删除联系人 / 加为好友等 store 操作都在本组件内闭环父级仅监听 chat / deleted / saved 等通知做导航 / 关浮层 / 同步副本
-->
<div>
<!-- 顶部头像 + 名字 + 性别图标 + 昵称 / 部门 + 右上 "..." 菜单 friend -->
<div class="flex gap-3 items-start">
<UserAvatar
:id="full?.id"
:url="full?.avatar"
:name="full?.nickname"
:size="56"
:clickable="false"
previewable
:preview-z-index="previewZIndex"
/>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<span
class="text-lg font-semibold leading-snug truncate text-[var(--el-text-color-primary)]"
>
{{ headerName }}
</span>
<!-- 性别小图标男蓝女粉未知 / 0 不展示对齐微信留白做法 -->
<Icon
v-if="genderIcon"
:icon="genderIcon"
:size="16"
:color="genderColor"
class="flex-shrink-0"
/>
</div>
<div class="mt-2 space-y-1 text-13px text-[var(--el-text-color-secondary)]">
<!-- 仅当备注已设时展示昵称副行;未设置时主标题就是 nickname避免重复 -->
<div v-if="displayName" class="truncate">昵称:{{ full?.nickname }}</div>
<div class="truncate">部门:{{ deptText }}</div>
</div>
</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">
<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)]" />
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="handleDeleteFriend">
<span class="text-[var(--el-color-danger)]">删除联系人</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<!-- 备注行:仅 friend 态展示,未编辑时整行可点;编辑态用 el-input 行内替换 value 列 -->
<template v-if="relation === 'friend'">
<div class="my-4 h-px bg-[var(--el-border-color-lighter)]"></div>
<div
class="flex gap-5 items-center px-1.5 py-1.5 text-sm"
:class="
!editingRemark
? 'group cursor-pointer rounded transition-colors hover:bg-[var(--el-fill-color-lighter)]'
: ''
"
@click="handleRowClick"
>
<span class="flex-shrink-0 w-14 text-[var(--el-text-color-secondary)]">备注</span>
<el-input
v-if="editingRemark"
ref="remarkInputRef"
v-model="remarkInput"
size="small"
maxlength="20"
placeholder="添加备注"
class="flex-1"
@click.stop
@blur="saveRemark"
@keyup.enter="saveRemark"
@keyup.esc="cancelEditRemark"
/>
<template v-else>
<span
class="flex-1 min-w-0 truncate"
:class="
displayName
? 'text-[var(--el-text-color-primary)]'
: 'text-[var(--el-text-color-placeholder)]'
"
>
{{ displayName || '添加备注' }}
</span>
<!-- 默认 opacity:0 占位避免 hover 时其它列位移;仅父行 hover 时浮现 -->
<Icon
icon="ant-design:edit-outlined"
:size="14"
class="flex-shrink-0 text-[var(--el-text-color-secondary)] opacity-0 group-hover:opacity-100 transition-opacity"
/>
</template>
</div>
</template>
<!-- 动作区:好友 = 3 图标;陌生人 = 加为好友按钮;自己 = disabledreadonly 不渲染 -->
<template v-if="relation !== 'readonly'">
<div class="my-4 h-px bg-[var(--el-border-color-lighter)]"></div>
<div v-if="relation === 'friend'" class="flex justify-around">
<!-- 三连图标按钮图上字下、主色作为可点暗示hover 降不透明度做反馈 -->
<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="handleChat"
>
<Icon icon="ant-design:message-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="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="handleComingSoon('视频聊天')"
>
<Icon icon="ant-design:video-camera-outlined" :size="22" />
<span>视频聊天</span>
</div>
</div>
<div v-else-if="relation === 'self'" class="flex justify-center">
<el-button type="primary" disabled>不能和自己聊天</el-button>
</div>
<div v-else class="flex justify-center">
<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>
<script lang="ts" setup>
import { computed, nextTick, ref, watch } from 'vue'
import type { InputInstance } from 'element-plus'
import Icon from '@/components/Icon/src/Icon.vue'
import { useMessage } from '@/hooks/web/useMessage'
import UserAvatar from './UserAvatar.vue'
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'
defineOptions({ name: 'ImUserInfo' })
/**
* 关系态:决定备注行 / 右上 "..." 菜单 / 底部动作区的内容
* - friend: 备注(可编辑)+ "..." 删除菜单 + 3 图标动作
* - stranger: 单按钮"加为好友"
* - self: 单按钮"不能和自己聊天" disabled
* - readonly: 不渲染备注 / 菜单 / 动作区,仅展示头部信息
*/
export type UserInfoRelation = 'friend' | 'stranger' | 'self' | 'readonly'
const props = withDefaults(
defineProps<{
/** 起手用户:至少要有 id性别 / 部门由组件按 id 懒拉合并补齐 */
user: User | null
relation?: UserInfoRelation
/** 备注:仅 relation=friend 时有效;空串 → 显示"添加备注"占位 */
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,
addSource: 1
}
)
const emit = defineEmits<{
/** 备注落库成功后通知父级,用于同步父级持有的本地 FriendLite / Friend 副本(如 contact 页的 selection */
saved: [value: string]
/** 用户点"发消息":导航 / 关浮层等场景相关动作由父级承担(比如 UserInfoCard 要 close */
chat: [user: User]
/** 删除联系人成功后通知父级confirm + 调 store 都在本组件内做完):父级关浮层 / 清选中等 */
deleted: [user: User]
}>()
const message = useMessage()
const friendStore = useFriendStore()
/** 起手 user + getSimpleUser 合并后的完整对象(性别 / 部门补齐用) */
const full = ref<User | null>(props.user)
/** 主标题:备注优先(好友场景),其次原昵称 */
const headerName = computed(() => props.displayName || full.value?.nickname || '')
const deptText = computed(() => full.value?.deptName || '-')
const genderIcon = computed(() => getGenderIcon(full.value?.sex))
const genderColor = computed(() => getGenderColor(full.value?.sex))
/** 备注内联编辑editingRemark 控制输入态user 切换时由下面的 watch 复位避免脏态泄漏 */
const editingRemark = ref(false)
const remarkInput = ref('')
const remarkInputRef = ref<InputInstance | null>(null)
/**
* user.id 变化的统一处理:
* 1. 起手用 prop 兜底首屏full = props.user再 getSimpleUser 命中后合并替换
* 2. 顺便复位备注编辑态,避免上一个用户的脏输入泄漏到下一个
* 3. 竞态用 id 比对丢弃陈旧响应
*/
watch(
() => props.user?.id,
async (id) => {
full.value = props.user
editingRemark.value = false
if (!id) {
return
}
const data = (await getSimpleUser(id)) as User
if (props.user?.id !== id) {
return
}
full.value = { ...props.user, ...data }
},
{ immediate: true }
)
/** 备注行点击:进编辑态 + 把当前备注灌进输入框,下一帧把焦点 / 全选交给 el-input */
async function handleRowClick() {
if (editingRemark.value) {
return
}
remarkInput.value = props.displayName || ''
editingRemark.value = true
await nextTick()
remarkInputRef.value?.focus()
remarkInputRef.value?.select()
}
/**
* 保存备注:
* 1. 重入保护——blur + Enter 同时触发只走一次(编辑态先复位)
* 2. 无变化跳过后端调用 + 不抛 saved避免父级误同步
* 3. 落库成功 / 失败都在组件内自闭:成功抛 saved 让父级同步本地副本,失败留编辑态外的展示值不动
*/
async function saveRemark() {
if (!editingRemark.value) {
return
}
const userId = props.user?.id
if (!userId) {
editingRemark.value = false
return
}
const next = remarkInput.value.trim()
editingRemark.value = false
if (next === (props.displayName || '')) {
return
}
await friendStore.setDisplayName(userId, next)
message.success('已更新备注')
emit('saved', next)
}
function cancelEditRemark() {
editingRemark.value = false
}
/** 发消息:导航 / 关浮层这些"业务侧"动作交给父级,本组件只负责通知 */
function handleChat() {
if (!props.user) {
return
}
emit('chat', props.user)
}
/** 占位提示:语音 / 视频聊天能力尚未接入,先以"开发中"友好提示 */
function handleComingSoon(featureName: string) {
message.info(`${featureName} 功能开发中`)
}
// ==================== 添加好友 / 删除好友 ====================
// 加好友弹窗显隐 + 预填用户(点「加为好友」时把 props.user 传给 FriendAddDialog 跳过搜索)
const addFriendVisible = ref(false)
const presetUserForAdd = ref<UserVO | null>(null)
/** 加为好友:弹 FriendAddDialog带预填用户让用户填申请理由 + 备注后再发申请 */
function handleAddFriend() {
if (!props.user?.id) {
return
}
presetUserForAdd.value = {
id: props.user.id,
nickname: props.user.nickname,
avatar: props.user.avatar,
sex: props.user.sex,
deptId: props.user.deptId,
deptName: props.user.deptName
} as UserVO
addFriendVisible.value = true
}
/** 删除联系人confirm → friendStore.deleteFriend内部级联清会话→ 通知父级关浮层 / 清选中 */
async function handleDeleteFriend() {
if (!props.user?.id) {
return
}
const target = props.user
// 二次确认
await message.confirm(`确定删除好友「${target.nickname || ''}」吗?`, '删除联系人')
// 删除好友
await friendStore.deleteFriend(target.id)
message.success('已删除好友')
emit('deleted', target)
}
</script>
<style>
/*
非 scopedel-dropdown 的下拉菜单走 teleport 到 bodyscoped 选不到。
UserInfoCard 浮层用 z-9998要把这块抬到更高默认 --el-popper-z-index ~2050 会被遮罩压住)。
*/
.im-user-info__more-menu {
z-index: 10000 !important;
}
</style>