448 lines
17 KiB
Vue
448 lines
17 KiB
Vue
<script lang="ts" setup>
|
||
import type { User } from '../../types'
|
||
|
||
import { computed, h, nextTick, ref, watch } from 'vue'
|
||
|
||
import { confirm } from '@vben/common-ui'
|
||
import { DICT_TYPE } from '@vben/constants'
|
||
import { getDictLabel } from '@vben/hooks'
|
||
import { IconifyIcon as Icon } from '@vben/icons'
|
||
|
||
import { Button, Checkbox, Dropdown, Input, Menu, message } from 'ant-design-vue'
|
||
|
||
import { getSimpleUser, type SystemUserApi } from '#/api/system/user'
|
||
import { formatDate } from '#/views/im/utils/time'
|
||
|
||
import { ImFriendAddSource } from '../../../utils/constants'
|
||
import { toUserCardTarget } from '../../../utils/message'
|
||
import { getGenderColor, getGenderIcon } from '../../../utils/user'
|
||
import { useFriendStore } from '../../store/friendStore'
|
||
import { FriendAddDialog } from '../friend'
|
||
import RecommendCardDialog from './recommend-card-dialog.vue'
|
||
import UserAvatar from './user-avatar.vue'
|
||
|
||
defineOptions({ name: 'ImUserInfo' })
|
||
|
||
const props = withDefaults(
|
||
defineProps<{
|
||
/** 加好友来源;默认 SEARCH;参见 ImFriendAddSource */
|
||
addSource?: number
|
||
/** 来源附带信息:addSource=GROUP(群聊)时传群名,用于「我是 XX 群的 YY」预填话术 */
|
||
addSourceExtra?: string
|
||
/** 备注:仅 relation=friend 时有效;空串 → 显示"添加备注"占位 */
|
||
displayName?: string
|
||
/** UserAvatar 预览层 z-index;放在高 z-index 浮层(如 UserInfoCard)里需手动抬高 */
|
||
previewZIndex?: number
|
||
relation?: UserInfoRelation
|
||
/** 起手用户:至少要有 id;性别 / 部门由组件按 id 懒拉合并补齐 */
|
||
user: null | User
|
||
}>(),
|
||
{
|
||
addSource: ImFriendAddSource.SEARCH,
|
||
addSourceExtra: '',
|
||
displayName: '',
|
||
previewZIndex: 2000,
|
||
relation: 'readonly'
|
||
}
|
||
)
|
||
|
||
const emit = defineEmits<{
|
||
/** 用户点"发消息":导航 / 关浮层等场景相关动作由父级承担(比如 UserInfoCard 要 close) */
|
||
chat: [user: User]
|
||
/** 删除联系人成功后通知父级(confirm + 调 store 都在本组件内做完):父级关浮层 / 清选中等 */
|
||
deleted: [user: User]
|
||
/** 备注落库成功后通知父级,用于同步父级持有的本地 FriendLite / Friend 副本(如 contact 页的 selection) */
|
||
saved: [value: string]
|
||
}>()
|
||
|
||
/**
|
||
* 关系态:决定备注行 / 右上 "..." 菜单 / 底部动作区的内容
|
||
* - friend: 备注(可编辑)+ "..." 删除菜单 + 3 图标动作
|
||
* - stranger: 单按钮"加为好友"
|
||
* - self: 单按钮"不能和自己聊天" disabled
|
||
* - readonly: 不渲染备注 / 菜单 / 动作区,仅展示头部信息
|
||
*/
|
||
export type UserInfoRelation = 'friend' | 'readonly' | 'self' | 'stranger'
|
||
|
||
const friendStore = useFriendStore()
|
||
|
||
const full = ref<null | User>(props.user) // 起手 user + getSimpleUser 合并后的完整对象(性别 / 部门补齐用)
|
||
|
||
/** 主标题:备注优先(好友场景),其次原昵称 */
|
||
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))
|
||
|
||
/** 好友关系记录:来源 / 添加时间 / 是否拉黑从这里取(仅 friend 态下才有意义) */
|
||
const friendInfo = computed(() =>
|
||
props.user?.id ? friendStore.getFriend(props.user.id) : undefined
|
||
)
|
||
|
||
/** 是否已拉黑:菜单项「加入黑名单 / 移出黑名单」按这个切换 */
|
||
const isBlocked = computed(() => !!friendInfo.value?.blocked)
|
||
|
||
const editingRemark = ref(false) // 备注内联编辑:editingRemark 控制输入态;user 切换时由下面的 watch 复位避免脏态泄漏
|
||
const remarkInput = ref('')
|
||
const remarkInputRef = ref<null | { focus: () => void; select?: () => void }>(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.setFriendDisplayName(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} 功能开发中`)
|
||
}
|
||
|
||
// ==================== 添加好友 / 删除好友 ====================
|
||
|
||
const friendAddDialogRef = ref<InstanceType<typeof FriendAddDialog>>() // 加好友弹窗 ref:handleAddFriend 调 open({ presetUser, addSource, addSourceExtra }) 触发
|
||
|
||
const recommendDialogRef = ref<InstanceType<typeof RecommendCardDialog>>() // 推荐名片弹窗 ref:handleRecommend 调用 open({ target }) 打开
|
||
/** 把他推荐给朋友:弹 RecommendCardDialog 选目标会话 */
|
||
function handleRecommend() {
|
||
if (!props.user?.id) {
|
||
return
|
||
}
|
||
const target = toUserCardTarget(full.value)
|
||
if (!target) {
|
||
return
|
||
}
|
||
recommendDialogRef.value?.open({ target })
|
||
}
|
||
|
||
/** 加为好友:弹 FriendAddDialog(带预填用户),让用户填申请理由 + 备注后再发申请 */
|
||
function handleAddFriend() {
|
||
if (!props.user?.id) {
|
||
return
|
||
}
|
||
const presetUser: SystemUserApi.UserSimple = {
|
||
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 SystemUserApi.UserSimple
|
||
friendAddDialogRef.value?.open({
|
||
presetUser,
|
||
addSource: props.addSource,
|
||
addSourceExtra: props.addSourceExtra
|
||
})
|
||
}
|
||
|
||
/** 加入黑名单:confirm → friendStore.blockFriend;后端 FRIEND_BLOCK 推到时由 dispatcher 同步多端 */
|
||
async function handleBlock() {
|
||
if (!props.user?.id) {
|
||
return
|
||
}
|
||
const target = props.user
|
||
try {
|
||
await confirm(`确定将「${target.nickname || ''}」加入黑名单吗?`, '加入黑名单')
|
||
} catch {
|
||
return
|
||
}
|
||
await friendStore.blockFriend(target.id)
|
||
message.success('已加入黑名单')
|
||
}
|
||
|
||
/** 移出黑名单:操作温和不弹 confirm;后端 FRIEND_UNBLOCK 推到时由 dispatcher 同步多端 */
|
||
async function handleUnblock() {
|
||
if (!props.user?.id) {
|
||
return
|
||
}
|
||
await friendStore.unblockFriend(props.user.id)
|
||
message.success('已移出黑名单')
|
||
}
|
||
|
||
/** 删除联系人:弹自定义确认(含「同时清空聊天记录」选项)→ friendStore.deleteFriend → 通知父级关浮层 / 清选中 */
|
||
async function handleDeleteFriend() {
|
||
if (!props.user?.id) {
|
||
return
|
||
}
|
||
const target = props.user
|
||
const clearConversation = ref(true)
|
||
try {
|
||
await confirm({
|
||
cancelText: '取消',
|
||
confirmText: '删除',
|
||
content: h('div', { class: 'flex flex-col gap-3 text-sm' }, [
|
||
h('div', `确定删除好友「${target.nickname || ''}」?`),
|
||
h(
|
||
Checkbox,
|
||
{
|
||
checked: clearConversation.value,
|
||
'onUpdate:checked': (value: boolean) => {
|
||
clearConversation.value = value
|
||
}
|
||
},
|
||
() => '同时清空聊天记录'
|
||
)
|
||
]),
|
||
icon: 'warning',
|
||
title: '删除联系人'
|
||
})
|
||
} catch {
|
||
return
|
||
}
|
||
await friendStore.deleteFriend(target.id, clearConversation.value)
|
||
message.success('已删除好友')
|
||
emit('deleted', target)
|
||
}
|
||
</script>
|
||
|
||
<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(--ant-color-text)]"
|
||
>
|
||
{{ 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(--ant-color-text-secondary)]">
|
||
<!-- 仅当备注已设时展示昵称副行;未设置时主标题就是 nickname,避免重复 -->
|
||
<div v-if="displayName" class="truncate">昵称:{{ full?.nickname }}</div>
|
||
<div class="truncate">部门:{{ deptText }}</div>
|
||
</div>
|
||
</div>
|
||
<!-- 右上 "..." 菜单:仅 friend 态展示;含「加入/移出黑名单」 + 「删除联系人」 -->
|
||
<div v-if="relation === 'friend'" class="flex-shrink-0">
|
||
<Dropdown :trigger="['click']" placement="bottomRight" overlay-class-name="im-user-info__more-menu">
|
||
<div
|
||
class="flex items-center justify-center w-7 h-7 rounded cursor-pointer hover:bg-[var(--ant-color-fill-secondary)]"
|
||
>
|
||
<Icon icon="ep:more-filled" :size="18" class="text-[var(--ant-color-text-secondary)]" />
|
||
</div>
|
||
<template #overlay>
|
||
<Menu>
|
||
<!-- 把他推荐给朋友:以个人名片消息形式发到选中的会话 -->
|
||
<Menu.Item @click="handleRecommend">把他推荐给朋友</Menu.Item>
|
||
<!-- 拉黑 / 移出黑名单:按 friendInfo.blocked 切换文案 -->
|
||
<Menu.Item v-if="!isBlocked" divided @click="handleBlock">
|
||
加入黑名单
|
||
</Menu.Item>
|
||
<Menu.Item v-else divided @click="handleUnblock">移出黑名单</Menu.Item>
|
||
<Menu.Item divided @click="handleDeleteFriend">
|
||
<span class="text-[var(--ant-color-error)]">删除联系人</span>
|
||
</Menu.Item>
|
||
</Menu>
|
||
</template>
|
||
</Dropdown>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 备注行:仅 friend 态展示,未编辑时整行可点;编辑态用 el-input 行内替换 value 列 -->
|
||
<template v-if="relation === 'friend'">
|
||
<div class="my-4 h-px bg-[var(--ant-color-border-secondary)]"></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(--ant-color-fill-tertiary)]'
|
||
: ''
|
||
"
|
||
@click="handleRowClick"
|
||
>
|
||
<span class="flex-shrink-0 w-16 whitespace-nowrap text-[var(--ant-color-text-secondary)]">备注</span>
|
||
<Input
|
||
v-if="editingRemark"
|
||
ref="remarkInputRef"
|
||
v-model:value="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(--ant-color-text)]'
|
||
: 'text-[var(--ant-color-text-placeholder)]'
|
||
"
|
||
>
|
||
{{ displayName || '添加备注' }}
|
||
</span>
|
||
<!-- 默认 opacity:0 占位避免 hover 时其它列位移;仅父行 hover 时浮现 -->
|
||
<Icon
|
||
icon="ant-design:edit-outlined"
|
||
:size="14"
|
||
class="flex-shrink-0 text-[var(--ant-color-text-secondary)] opacity-0 group-hover:opacity-100 transition-opacity"
|
||
/>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- ==================== 更多信息:来源 / 添加时间,对齐微信「朋友资料」分块 ==================== -->
|
||
<template v-if="friendInfo?.addSource || friendInfo?.addTime">
|
||
<div class="my-4 h-px bg-[var(--ant-color-border-secondary)]"></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(--ant-color-text-secondary)]">来源</span>
|
||
<span class="flex-1 min-w-0 truncate text-[var(--ant-color-text)]">
|
||
{{ 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(--ant-color-text-secondary)]">添加时间</span>
|
||
<span class="flex-1 min-w-0 truncate text-[var(--ant-color-text)]">
|
||
{{ formatDate(new Date(friendInfo.addTime), 'YYYY-MM-DD') }}
|
||
</span>
|
||
</div>
|
||
</template>
|
||
</template>
|
||
|
||
<!-- 动作区:好友 = 3 图标;陌生人 = 加为好友按钮;自己 = disabled;readonly 不渲染 -->
|
||
<template v-if="relation !== 'readonly'">
|
||
<div class="my-4 h-px bg-[var(--ant-color-border-secondary)]"></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(--ant-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(--ant-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(--ant-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">
|
||
<Button type="primary" disabled>不能和自己聊天</Button>
|
||
</div>
|
||
<div v-else class="flex justify-center">
|
||
<Button type="primary" @click="handleAddFriend">加为好友</Button>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 加好友弹窗:携带预填用户跳过搜索步骤,直接进申请表单(理由按 addSource 区分话术) -->
|
||
<FriendAddDialog ref="friendAddDialogRef" />
|
||
|
||
<!-- 把他推荐给朋友弹窗:仅 friend 态下出现入口 -->
|
||
<RecommendCardDialog ref="recommendDialogRef" />
|
||
</div>
|
||
</template>
|
||
|
||
<style>
|
||
/*
|
||
非 scoped:el-dropdown 的下拉菜单走 teleport 到 body,scoped 选不到。
|
||
UserInfoCard 浮层用 z-9998,要把这块抬到更高(默认 --ant-z-index-popup-base ~2050 会被遮罩压住)。
|
||
*/
|
||
.im-user-info__more-menu {
|
||
z-index: 10000 !important;
|
||
}
|
||
</style>
|