feat(im): 优化整体包结构,将 friend、group 通用组件抽过去。

im
YunaiV 2026-04-30 10:11:20 +08:00
parent 4b64153044
commit a762dfff84
34 changed files with 1047 additions and 647 deletions

View File

@ -0,0 +1,20 @@
import request from '@/config/axios'
export interface ImManagerFriendVO {
id: number
userId: number
userNickname?: string
friendUserId: number
friendNickname?: string
displayName?: string
muted: boolean
status: number
addTime?: Date
deleteTime?: Date
createTime: Date
}
// 获得好友关系分页
export const getManagerFriendPage = (params: PageParam) => {
return request.get({ url: '/im/manager/friend/page', params })
}

View File

@ -0,0 +1,28 @@
import request from '@/config/axios'
// TODO @AI应该是 message/group/xxx保持和前端一致
export interface ImManagerGroupMessageVO {
id: number
clientMessageId?: string
groupId: number
groupName?: string
senderId: number
senderNickname?: string
type: number
content: string
status: number
atUserIds?: number[]
receiptStatus?: number
sendTime: Date
createTime: Date
}
// 获得群聊消息分页
export const getManagerGroupMessagePage = (params: PageParam) => {
return request.get({ url: '/im/manager/message/group/page', params })
}
// 获得群聊消息详情
export const getManagerGroupMessage = (id: number) => {
return request.get({ url: '/im/manager/message/group/get?id=' + id })
}

View File

@ -0,0 +1,26 @@
import request from '@/config/axios'
// TODO @AI应该是 message/group/xxx保持和前端一致
export interface ImManagerPrivateMessageVO {
id: number
clientMessageId?: string
senderId: number
senderNickname?: string
receiverId: number
receiverNickname?: string
type: number
content: string
status: number
sendTime: Date
createTime: Date
}
// 获得私聊消息分页
export const getManagerPrivateMessagePage = (params: PageParam) => {
return request.get({ url: '/im/manager/message/private/page', params })
}
// 获得私聊消息详情
export const getManagerPrivateMessage = (id: number) => {
return request.get({ url: '/im/manager/message/private/get?id=' + id })
}

View File

@ -0,0 +1,42 @@
import request from '@/config/axios'
export interface ImManagerSensitiveWordVO {
id: number
word: string
status: number
creator?: string
createTime?: Date
}
// 获得敏感词分页
export const getManagerSensitiveWordPage = (params: PageParam) => {
return request.get({ url: '/im/manager/sensitive-word/page', params })
}
// 获得敏感词详情
export const getManagerSensitiveWord = (id: number) => {
return request.get({ url: '/im/manager/sensitive-word/get?id=' + id })
}
// 新增敏感词
export const createManagerSensitiveWord = (data: ImManagerSensitiveWordVO) => {
return request.post({ url: '/im/manager/sensitive-word/create', data })
}
// 修改敏感词
export const updateManagerSensitiveWord = (data: ImManagerSensitiveWordVO) => {
return request.put({ url: '/im/manager/sensitive-word/update', data })
}
// 删除敏感词
export const deleteManagerSensitiveWord = (id: number) => {
return request.delete({ url: '/im/manager/sensitive-word/delete?id=' + id })
}
// 批量删除敏感词
export const deleteManagerSensitiveWordList = (ids: number[]) => {
return request.delete({
url: '/im/manager/sensitive-word/delete-list',
params: { ids: ids.join(',') }
})
}

View File

@ -0,0 +1,122 @@
// IM 数据看板 API
//
// vibe 阶段:所有方法用本地 mocksetTimeout + 随机数据)实现,不发真实请求。
// 后端就绪后把 mockXxx 调用替换为 request.get(...) 一行即可。
//
// import request from '@/config/axios'
export interface ImStatisticsOverviewVO {
totalUser: number
newUserToday: number
totalGroup: number
newGroupToday: number
activeUserDaily: number
activeUserWeekly: number
activeUserMonthly: number
privateMessageToday: number
groupMessageToday: number
privateMessageYesterday: number
groupMessageYesterday: number
}
export interface ImStatisticsTrendVO {
dates: string[]
series: Record<string, number[]>
}
export interface ImStatisticsDistributionVO {
messageTypeDistribution: { name: string; value: number }[]
groupSizeDistribution: { range: string; count: number }[]
topSenders: { userId: number; nickname: string; messageCount: number }[]
}
// ==================== mock helpers ====================
const fakePromise = <T>(data: T, delay = 300): Promise<T> =>
new Promise((r) => setTimeout(() => r(data), delay))
const randomInt = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min
const buildDates = (days: number): string[] => {
const dates: string[] = []
const today = new Date()
for (let i = days - 1; i >= 0; i--) {
const d = new Date(today)
d.setDate(d.getDate() - i)
dates.push(d.toISOString().slice(0, 10))
}
return dates
}
// ==================== exposed APIs ====================
// 获得 KPI 概览
export const getStatisticsOverview = (): Promise<ImStatisticsOverviewVO> => {
return fakePromise<ImStatisticsOverviewVO>({
totalUser: 12345,
newUserToday: 23,
totalGroup: 678,
newGroupToday: 4,
activeUserDaily: 1023,
activeUserWeekly: 4567,
activeUserMonthly: 8901,
privateMessageToday: 8765,
groupMessageToday: 3210,
privateMessageYesterday: 7890,
groupMessageYesterday: 3000
})
// 真实请求版本return request.get({ url: '/im/manager/statistics/overview' })
}
// 获得消息趋势(私聊 + 群聊双线)
export const getMessageTrend = (days: number): Promise<ImStatisticsTrendVO> => {
const dates = buildDates(days)
return fakePromise<ImStatisticsTrendVO>({
dates,
series: {
private: dates.map(() => randomInt(500, 2000)),
group: dates.map(() => randomInt(200, 1200))
}
})
// return request.get({ url: '/im/manager/statistics/message-trend', params: { days } })
}
// 获得用户趋势(新增注册 + 日活双线)
export const getUserTrend = (days: number): Promise<ImStatisticsTrendVO> => {
const dates = buildDates(days)
return fakePromise<ImStatisticsTrendVO>({
dates,
series: {
register: dates.map(() => randomInt(5, 80)),
active: dates.map(() => randomInt(800, 1500))
}
})
// return request.get({ url: '/im/manager/statistics/user-trend', params: { days } })
}
// 获得分布数据(消息类型 / 群规模 / TOP 发送者)
export const getStatisticsDistribution = (): Promise<ImStatisticsDistributionVO> => {
return fakePromise<ImStatisticsDistributionVO>({
messageTypeDistribution: [
{ name: '文本', value: 8000 },
{ name: '图片', value: 2400 },
{ name: '视频', value: 320 },
{ name: '语音', value: 980 },
{ name: '文件', value: 540 },
{ name: '位置', value: 65 },
{ name: '名片', value: 32 }
],
groupSizeDistribution: [
{ range: '1-9 人', count: 320 },
{ range: '10-49 人', count: 240 },
{ range: '50-199 人', count: 95 },
{ range: '200+ 人', count: 23 }
],
topSenders: Array.from({ length: 10 }, (_, i) => ({
userId: 1000 + i,
nickname: `测试用户${i + 1}`,
messageCount: 1500 - i * 120
}))
})
// return request.get({ url: '/im/manager/statistics/distribution' })
}

View File

@ -768,36 +768,51 @@ const remainingRouter: AppRouteRecordRaw[] = [
meta: { hidden: true, title: '消息' }
},
{
path: 'friend',
component: () => import('@/views/im/home/pages/friend/index.vue'),
name: 'ImHomeFriend',
meta: { hidden: true, title: '好友' }
},
{
path: 'group',
component: () => import('@/views/im/home/pages/group/index.vue'),
name: 'ImHomeGroup',
meta: { hidden: true, title: '群聊' }
path: 'contact',
component: () => import('@/views/im/home/pages/contact/index.vue'),
name: 'ImHomeContact',
meta: { hidden: true, title: '通讯录' }
}
]
},
{
path: 'manager/message',
path: 'manager/message/private',
component: Layout,
name: 'ImManagerMessage',
redirect: '/im/manager/message/index',
name: 'ImManagerPrivateMessage',
redirect: '/im/manager/message/private/index',
meta: { hidden: false },
children: [
{
path: 'index',
component: () => import('@/views/im/manager/message/index.vue'),
name: 'ImManagerMessageIndex',
component: () => import('@/views/im/manager/message/private/index.vue'),
name: 'ImManagerPrivateMessageIndex',
meta: {
canTo: true,
hidden: false,
noTagsView: false,
icon: 'ep:chat-dot-round',
title: '消息管理'
icon: 'ep:user',
title: '私聊消息'
}
}
]
},
{
path: 'manager/message/group',
component: Layout,
name: 'ImManagerGroupMessage',
redirect: '/im/manager/message/group/index',
meta: { hidden: false },
children: [
{
path: 'index',
component: () => import('@/views/im/manager/message/group/index.vue'),
name: 'ImManagerGroupMessageIndex',
meta: {
canTo: true,
hidden: false,
noTagsView: false,
icon: 'ep:chat-line-round',
title: '群聊消息'
}
}
]
@ -843,6 +858,48 @@ const remainingRouter: AppRouteRecordRaw[] = [
}
}
]
},
{
path: 'manager/sensitive-word',
component: Layout,
name: 'ImManagerSensitiveWord',
redirect: '/im/manager/sensitive-word/index',
meta: { hidden: false },
children: [
{
path: 'index',
component: () => import('@/views/im/manager/sensitive-word/index.vue'),
name: 'ImManagerSensitiveWordIndex',
meta: {
canTo: true,
hidden: false,
noTagsView: false,
icon: 'ep:warning',
title: '敏感词管理'
}
}
]
},
{
path: 'manager/statistics',
component: Layout,
name: 'ImManagerStatistics',
redirect: '/im/manager/statistics/index',
meta: { hidden: false },
children: [
{
path: 'index',
component: () => import('@/views/im/manager/statistics/index.vue'),
name: 'ImManagerStatisticsIndex',
meta: {
canTo: true,
hidden: false,
noTagsView: false,
icon: 'ep:trend-charts',
title: '数据看板'
}
}
]
}
]
}

View File

@ -55,7 +55,7 @@ import { useRoute, useRouter } from 'vue-router'
import Icon from '@/components/Icon/src/Icon.vue'
import { useUserStore } from '@/store/modules/user'
import { useConversationStore } from '../store/conversationStore'
import UserAvatar from './UserAvatar.vue'
import UserAvatar from './user/UserAvatar.vue'
defineOptions({ name: 'ImToolBar' })
@ -68,15 +68,14 @@ const conversationStore = useConversationStore()
const totalUnread = computed(() => conversationStore.getTotalUnread)
/**
* 个主 Tab 的配置name 对应路由 ImHomeConversation/Friend/Group
* 个主 Tab 的配置name 对应路由 ImHomeConversation/Contact
* name 而非 pathpath 后期容易变前缀调整嵌套加层name 更稳定
* icon 走通用 <Icon> 组件支持 iconify 全部前缀ep: / ant-design: / svg-icon:
* 群聊用 ant-design:team三人组合ep 没有"群体"图标三人剪影跟 ep:user单人一眼区分单人 / 群体
* 通讯录用 ant-design:contacts-outlined与消息图标一眼区分好友 + 群聊在通讯录内分组展示
*/
const tabs = [
{ name: 'ImHomeConversation', label: '消息', icon: 'ep:chat-dot-round' },
{ name: 'ImHomeFriend', label: '好友', icon: 'ep:user' },
{ name: 'ImHomeGroup', label: '群聊', icon: 'ant-design:team' }
{ name: 'ImHomeContact', label: '通讯录', icon: 'ant-design:contacts-outlined' }
]
/** 当前路由是否命中 Tab直接比对 route.name */

View File

@ -1,179 +0,0 @@
<template>
<!--
用户名片
Index.vue 挂载通过 useImUiStore.openUserInfoCard(user, position) 触发点遮罩 / Esc 关闭
-->
<teleport to="body">
<div v-if="card.show" class="fixed inset-0 z-9998" @click.self="handleClose">
<div
class="fixed w-80 p-4 bg-[var(--el-bg-color-overlay)] rounded-md shadow-xl"
:style="{ left: card.position.x + 'px', top: card.position.y + 'px' }"
@click.stop
>
<div class="flex items-center gap-3">
<UserAvatar
:url="user?.avatar"
:name="user?.nickname"
:size="60"
:clickable="false"
previewable
:preview-z-index="10000"
/>
<div class="flex-1 min-w-0">
<div class="text-16px font-semibold text-[var(--el-text-color-primary)]">
{{ user?.nickname || '-' }}
</div>
<div class="mt-1 text-13px break-all text-[var(--el-text-color-regular)]">
<span class="text-[var(--el-text-color-secondary)]">部门</span>
<span>{{ user?.deptName || '-' }}</span>
</div>
<div class="mt-1 text-13px break-all text-[var(--el-text-color-regular)]">
<span class="text-[var(--el-text-color-secondary)]">性别</span>
<span>{{ sexLabel }}</span>
</div>
</div>
</div>
<el-divider class="!my-3.5" />
<div class="flex gap-2 justify-center">
<el-button v-if="isSelf" type="primary" disabled>不能和自己聊天</el-button>
<el-button v-else-if="isFriend" type="primary" @click="handleSendMessage"
>发消息</el-button
>
<el-button v-else type="primary" @click="handleAddFriend"></el-button>
</div>
</div>
</div>
</teleport>
</template>
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from '@/hooks/web/useMessage'
import { DICT_TYPE, getDictLabel } from '@/utils/dict'
import { getSimpleUser } from '@/api/system/user'
import { useUserStore } from '@/store/modules/user'
import { useImUiStore } from '../store/uiStore'
import { useConversationStore } from '../store/conversationStore'
import { useFriendStore } from '../store/friendStore'
import { getFriendDisplayName } from '../../utils/user'
import { ImConversationType } from '../../utils/constants'
import type { UserInfo } from '../types'
import UserAvatar from './UserAvatar.vue'
defineOptions({ name: 'ImUserInfoCard' })
const uiStore = useImUiStore()
const conversationStore = useConversationStore()
const friendStore = useFriendStore()
const userStore = useUserStore()
const router = useRouter()
const message = useMessage()
const card = computed(() => uiStore.userInfoCard)
const user = computed(() => card.value.user)
const sexLabel = computed(() => {
if (user.value?.sex == null) {
return '-'
}
return getDictLabel(DICT_TYPE.SYSTEM_USER_SEX, user.value.sex) || '-'
})
const isSelf = computed(() => {
const myId = Number(userStore.getUser?.id) || 0
return !!user.value?.id && user.value.id === myId
})
const isFriend = computed(() => {
if (!user.value?.id || isSelf.value) {
return false
}
return friendStore.isFriend(user.value.id)
})
/** 名片打开时拉一次完整信息(部门 / 性别),覆盖调用方传入的最小集 */
watch(
() => card.value.show,
async (show) => {
if (!show) {
return
}
const id = user.value?.id
if (!id) {
return
}
try {
const data = (await getSimpleUser(id)) as UserInfo
//
if (data && card.value.show && card.value.user?.id === id) {
Object.assign(card.value.user, data)
}
} catch (e) {
console.warn('[IM] 拉取用户名片信息失败', e)
}
}
)
/** 关闭名片:点击遮罩或按 Esc 都会触发,直接调用 uiStore 关掉就行 */
function handleClose() {
uiStore.closeUserInfoCard()
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && card.value.show) {
handleClose()
}
}
onMounted(() => window.addEventListener('keydown', handleKeydown))
onUnmounted(() => window.removeEventListener('keydown', handleKeydown))
/** 发消息:打开私聊会话,跳转到聊天 tab */
function handleSendMessage() {
if (!user.value) {
return
}
// friendStore /
const friend = friendStore.getFriend(user.value.id)
const conversationName = friend
? getFriendDisplayName(friend)
: user.value.nickname || ''
conversationStore.openConversation(
user.value.id,
ImConversationType.PRIVATE,
conversationName,
user.value.avatar || '',
{ muted: !!friend?.muted }
)
// tab
if (router.currentRoute.value.name !== 'ImHomeConversation') {
router.push({ name: 'ImHomeConversation' })
}
uiStore.closeUserInfoCard()
}
/** 加为好友:直接加,成功后名片会自动切换到「发消息」按钮 */
async function handleAddFriend() {
if (!user.value?.id) {
return
}
try {
await friendStore.addFriend(user.value.id, {
nickname: user.value.nickname,
avatar: user.value.avatar
})
message.success('已添加好友')
} catch (e: any) {
console.error(
'[IM] 添加好友失败',
{ userId: user.value?.id, nickname: user.value?.nickname },
e
)
message.error(e?.message || '添加好友失败')
}
}
</script>

View File

@ -0,0 +1,74 @@
<template>
<!--
好友单行项
- 头像 + 昵称
- 选中态 active
- 右键菜单发消息 / 删除好友由全局 ContextMenu 承接
-->
<div
class="relative flex items-center gap-2.5 px-4 py-3 cursor-pointer transition-colors hover:bg-[var(--el-fill-color)]"
:class="{ '!bg-[#d9ecff] dark:!bg-[var(--el-color-primary-light-8)]': active }"
@click="$emit('click', friend)"
@contextmenu.prevent="handleContextMenu"
>
<UserAvatar
:id="friend.id"
:url="friend.avatar"
:name="friend.nickname"
:size="42"
:clickable="false"
/>
<div class="flex flex-1 min-w-0">
<!-- 单行展示 displayName 优先昵称仅在好友详情面板展示列表里不重复 -->
<div class="overflow-hidden text-sm truncate text-[var(--el-text-color-primary)]">
{{ friend.displayName || friend.nickname }}
</div>
</div>
<slot></slot>
</div>
</template>
<script lang="ts" setup>
import { useImUiStore } from '../../store/uiStore'
import UserAvatar from '../user/UserAvatar.vue'
import type { FriendLite } from '../../types'
defineOptions({ name: 'ImFriendItem' })
const props = withDefaults(
defineProps<{
friend: FriendLite
active?: boolean
menu?: boolean //
}>(),
{
active: false,
menu: true
}
)
const emit = defineEmits<{
click: [friend: FriendLite]
chat: [friend: FriendLite]
delete: [friend: FriendLite]
}>()
const uiStore = useImUiStore()
function handleContextMenu(e: MouseEvent) {
if (!props.menu) {
return
}
uiStore.openContextMenu(
{ x: e.clientX, y: e.clientY },
[
{ key: 'chat', name: '发送消息' },
{ key: 'delete', name: '删除好友' }
],
(item) => {
if (item.key === 'chat') emit('chat', props.friend)
else if (item.key === 'delete') emit('delete', props.friend)
}
)
}
</script>

View File

@ -0,0 +1,42 @@
<template>
<!--
群单行项
- 头像 + 群名
- 选中态 active
-->
<div
class="relative flex items-center gap-2.5 px-4 py-3 cursor-pointer transition-colors hover:bg-[var(--el-fill-color)]"
:class="{ '!bg-[#d9ecff] dark:!bg-[var(--el-color-primary-light-8)]': active }"
@click="$emit('click', group)"
>
<UserAvatar
:url="group.showImage || group.showImageThumb"
:name="group.showGroupName || group.name"
:size="42"
:clickable="false"
/>
<div class="flex flex-1 min-w-0">
<!-- 单行展示群名成员数仅在群详情面板展示列表里不重复 -->
<div class="overflow-hidden text-sm truncate text-[var(--el-text-color-primary)]">
{{ group.showGroupName || group.name }}
</div>
</div>
<slot></slot>
</div>
</template>
<script lang="ts" setup>
import UserAvatar from '../user/UserAvatar.vue'
import type { GroupLite } from '../../types'
defineOptions({ name: 'ImGroupItem' })
defineProps<{
group: GroupLite
active?: boolean
}>()
defineEmits<{
click: [group: GroupLite]
}>()
</script>

View File

@ -27,7 +27,7 @@
<script lang="ts" setup>
import { computed } from 'vue'
import UserAvatar from './UserAvatar.vue'
import UserAvatar from '../user/UserAvatar.vue'
defineOptions({ name: 'ImGroupMember' })

View File

@ -0,0 +1,55 @@
<template>
<!--
群成员行形态
- 横排 hover slot checkbox / 操作按钮等
- GroupMember 的差别 hover + slot 扩展点适合 selector / admin 列表
-->
<div
class="relative flex gap-2.5 items-center mx-px px-4 box-border whitespace-nowrap rounded cursor-pointer transition-colors hover:bg-[var(--el-fill-color)]"
:class="{ '!bg-[#e1eaf7] dark:!bg-[var(--el-color-primary-light-8)]': active }"
:style="{ height: height + 'px' }"
@click="$emit('click', member)"
>
<UserAvatar
:id="member.userId"
:url="member.avatar"
:name="member.nickname"
:size="avatarSize"
:clickable="false"
/>
<div
class="flex-1 h-full pl-1 overflow-hidden text-sm truncate text-[var(--el-text-color-primary)]"
:style="{ lineHeight: height + 'px' }"
>
{{ member.showName }}
</div>
<slot></slot>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import UserAvatar from '../user/UserAvatar.vue'
import type { GroupMemberLite } from './GroupMember.vue'
defineOptions({ name: 'ImGroupMemberItem' })
const props = withDefaults(
defineProps<{
member: GroupMemberLite
height?: number
active?: boolean
}>(),
{
height: 50,
active: false
}
)
defineEmits<{
click: [member: GroupMemberLite]
}>()
const avatarSize = computed(() => Math.ceil(props.height * 0.75))
</script>

View File

@ -44,8 +44,8 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { useImUiStore } from '../store/uiStore'
import type { UserInfo } from '../types'
import { useImUiStore } from '../../store/uiStore'
import type { User } from '../../types'
defineOptions({ name: 'ImUserAvatar', inheritAttrs: false })
@ -59,7 +59,7 @@ const props = withDefaults(
clickable?: boolean // UserInfoCard true
previewable?: boolean // clickable
previewZIndex?: number // z-index z-index UserInfoCard
user?: UserInfo //
user?: User //
}>(),
{
size: 42,

View File

@ -0,0 +1,377 @@
<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="onRowClick"
>
<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="onComingSoon('语音聊天')"
>
<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('视频聊天')"
>
<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>
</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 { getSimpleUser } from '@/api/system/user'
import { useFriendStore } from '../../store/friendStore'
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
}>(),
{
relation: 'readonly',
previewZIndex: 2000
}
)
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 || '-')
/** 性别图标:男 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 ''
})
/** 备注内联编辑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
}
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)
}
},
{ immediate: true }
)
// TODO @AI使 handleXXX
// TODO @AI await
// TODO @AI
function onRowClick() {
if (editingRemark.value) {
return
}
remarkInput.value = props.displayName || ''
editingRemark.value = true
void 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
}
try {
await friendStore.setDisplayName(userId, next)
message.success('已更新备注')
emit('saved', next)
} catch (e) {
// TODO @AI userId
console.error('[IM] 更新备注失败', e)
message.error('更新备注失败')
}
}
function cancelEditRemark() {
editingRemark.value = false
}
/** 发消息:导航 / 关浮层这些"业务侧"动作交给父级,本组件只负责通知 */
function handleChat() {
if (!props.user) {
return
}
emit('chat', props.user)
}
/** 加为好友:成功后 friendStore 反应到 isFriend父级的 relation 自然翻 friend本组件随之换装到 3 图标 */
async function handleAddFriend() {
if (!props.user?.id) {
return
}
try {
await friendStore.addFriend(props.user.id, {
nickname: props.user.nickname,
avatar: props.user.avatar
})
message.success('已添加好友')
} catch (e: any) {
console.error('[IM] 添加好友失败', e)
message.error(e?.message || '添加好友失败')
}
}
/** 删除联系人confirm → friendStore.deleteFriend内部级联清会话→ 通知父级关浮层 / 清选中 */
async function handleDeleteFriend() {
if (!props.user?.id) {
return
}
const target = props.user
try {
await message.confirm(`确定删除好友「${target.nickname || ''}」吗?`, '删除联系人')
await friendStore.deleteFriend(target.id)
message.success('已删除好友')
emit('deleted', target)
} catch (e) {
if (e !== 'cancel' && e !== 'close') {
console.error('[IM] 删除好友失败', e)
message.error('删除好友失败')
}
}
}
/** 占位提示:语音 / 视频聊天能力尚未接入,先以"开发中"友好提示 */
function onComingSoon(featureName: string) {
message.info(`${featureName} 功能开发中`)
}
</script>
<!--
TODO @AI这个是不是注释到 style
scopedel-dropdown 的下拉菜单走 teleport bodyscoped 选不到
UserInfoCard 浮层用 z-9998要把这块抬到更高默认 --el-popper-z-index ~2050 会被遮罩压住
-->
<style>
.im-user-info__more-menu {
z-index: 10000 !important;
}
</style>

View File

@ -0,0 +1,119 @@
<template>
<!--
用户名片浮层
- 仅承担"浮层定位 + 关闭逻辑(点遮罩 / Esc"名片视觉走 <UserInfo> contact 详情共用一份组件
- 触发useImUiStore.openUserInfoCard(user, position)本组件订阅 store全局只挂一份实例
- 关系态由 isSelf / isFriend 派生 relation prop 透到 UserInfo删除 / 加好友 / 备注落库都在 UserInfo 内闭环
-->
<teleport to="body">
<div v-if="card.show" class="fixed inset-0 z-9998" @click.self="handleClose">
<div
class="fixed w-80 p-4 bg-[var(--el-bg-color-overlay)] rounded-md shadow-xl"
:style="{ left: card.position.x + 'px', top: card.position.y + 'px' }"
@click.stop
>
<UserInfo
:user="user"
:display-name="remark"
:relation="relation"
:preview-z-index="10000"
@chat="handleSendMessage"
@deleted="handleClose"
/>
</div>
</div>
</teleport>
</template>
<script lang="ts" setup>
import { computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/store/modules/user'
import { useImUiStore } from '../../store/uiStore'
import { useConversationStore } from '../../store/conversationStore'
import { useFriendStore } from '../../store/friendStore'
import { getFriendDisplayName } from '../../../utils/user'
import { ImConversationType } from '../../../utils/constants'
import UserInfo, { type UserInfoRelation } from './UserInfo.vue'
defineOptions({ name: 'ImUserInfoCard' })
const uiStore = useImUiStore()
const conversationStore = useConversationStore()
const friendStore = useFriendStore()
const userStore = useUserStore()
const router = useRouter()
const card = computed(() => uiStore.userInfoCard)
const user = computed(() => card.value.user)
const isSelf = computed(() => {
const myId = Number(userStore.getUser?.id) || 0
return !!user.value?.id && user.value.id === myId
})
const isFriend = computed(() => {
if (!user.value?.id || isSelf.value) {
return false
}
return friendStore.isFriend(user.value.id)
})
const relation = computed<UserInfoRelation>(() => {
if (!user.value) {
return 'readonly'
}
if (isSelf.value) {
return 'self'
}
if (isFriend.value) {
return 'friend'
}
return 'stranger'
})
const remark = computed(() => {
if (!isFriend.value || !user.value?.id) {
return undefined
}
return friendStore.getFriend(user.value.id)?.displayName || ''
})
/** 关闭名片:点击遮罩 / Esc / UserInfo 抛上来的删除成功事件 */
function handleClose() {
uiStore.closeUserInfoCard()
}
/** 键盘事件Esc 键关闭名片 */
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && card.value.show) {
handleClose()
}
}
onMounted(() => window.addEventListener('keydown', handleKeydown))
onUnmounted(() => window.removeEventListener('keydown', handleKeydown))
/** 发消息UserInfo 抛上来的"发消息",由本组件接力做 openConversation + 跳消息 Tab + 关浮层 */
function handleSendMessage() {
if (!user.value) {
return
}
// friendStore /
const friend = friendStore.getFriend(user.value.id)
const conversationName = friend
? getFriendDisplayName(friend)
: user.value.nickname || ''
conversationStore.openConversation(
user.value.id,
ImConversationType.PRIVATE,
conversationName,
user.value.avatar || '',
{ muted: !!friend?.muted }
)
// Tab
if (router.currentRoute.value.name !== 'ImHomeConversation') {
router.push({ name: 'ImHomeConversation' })
}
uiStore.closeUserInfoCard()
}
</script>

View File

@ -35,7 +35,7 @@ import { useMessagePuller } from './composables/useMessagePuller'
import { useMessageSender } from './composables/useMessageSender'
import { ImConversationType } from '../utils/constants'
import ToolBar from './components/ToolBar.vue'
import UserInfoCard from './components/UserInfoCard.vue'
import UserInfoCard from './components/user/UserInfoCard.vue'
import ContextMenu from './components/ContextMenu.vue'
defineOptions({ name: 'ImIndex' })

View File

@ -274,8 +274,8 @@
</div>
</div>
<!-- 子对话框 page 引用 pages/group/ 下的组件 -->
<AddGroupMemberDialog
<!-- 子对话框邀请新成员 / 选成员移除 -->
<GroupMemberAddDialog
v-model="inviteVisible"
:group-id="group?.id"
:members="members"
@ -305,15 +305,13 @@ import { quitGroup, removeGroupMember, updateGroupMember } from '@/api/im/group/
import { useConversationStore } from '../../../../store/conversationStore'
import { useGroupStore } from '../../../../store/groupStore'
import { ImConversationType } from '@/views/im/utils/constants'
import GroupMemberGrid from '../../../group/components/GroupMemberGrid.vue'
import AddGroupMemberDialog from '../../../group/components/AddGroupMemberDialog.vue'
import GroupMemberGrid from '../../../../components/group/GroupMemberGrid.vue'
import GroupMemberAddDialog from '../../../../components/group/GroupMemberAddDialog.vue'
import GroupMemberSelector, {
type GroupMemberFlag
} from '../../../group/components/GroupMemberSelector.vue'
import type { GroupLite } from '../../../group/components/GroupItem.vue'
import type { Conversation } from '../../../../types'
import type { GroupMemberLite } from '../../../../components/GroupMember.vue'
import type { FriendLite } from '../../../friend/components/FriendItem.vue'
} from '../../../../components/group/GroupMemberSelector.vue'
import type { Conversation, FriendLite, GroupLite } from '../../../../types'
import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue'
defineOptions({ name: 'ImConversationGroupSide' })

View File

@ -83,7 +83,7 @@ import { ImConversationType, ImMessageType, isNormalMessage } from '../../../../
import { getSenderDisplayName } from '@/views/im/utils/user'
import { buildRecallTip } from '@/views/im/utils/conversation'
import type { Conversation } from '../../../../types'
import UserAvatar from '../../../../components/UserAvatar.vue'
import UserAvatar from '../../../../components/user/UserAvatar.vue'
defineOptions({ name: 'ImConversationItem' })

View File

@ -111,7 +111,7 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import UserAvatar from '../../../../components/UserAvatar.vue'
import UserAvatar from '../../../../components/user/UserAvatar.vue'
import { useMessage } from '@/hooks/web/useMessage'
import { useConversationStore } from '@/views/im/home/store/conversationStore'

View File

@ -65,7 +65,7 @@ import Icon from '@/components/Icon/src/Icon.vue'
import { useUserStore } from '@/store/modules/user'
import { CommonStatusEnum } from '@/utils/constants'
import { IM_AT_ALL_NICKNAME, IM_AT_ALL_USER_ID } from '@/views/im/utils/constants'
import GroupMember, { type GroupMemberLite } from '../../../../components/GroupMember.vue'
import GroupMember, { type GroupMemberLite } from '../../../../components/group/GroupMember.vue'
defineOptions({ name: 'ImMentionPicker' })

View File

@ -137,7 +137,7 @@ import {
import EmojiPicker from './EmojiPicker.vue'
import MentionPicker from './MentionPicker.vue'
import VoiceRecorder from './VoiceRecorder.vue'
import type { GroupMemberLite } from '../../../../components/GroupMember.vue'
import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue'
defineOptions({ name: 'ImMessageInput' })

View File

@ -318,8 +318,8 @@ import {
type AudioMessage
} from '@/views/im/utils/message'
import type { Message } from '@/views/im/home/types'
import UserAvatar from '../../../../components/UserAvatar.vue'
import GroupMember, { type GroupMemberLite } from '../../../../components/GroupMember.vue'
import UserAvatar from '../../../../components/user/UserAvatar.vue'
import GroupMember, { type GroupMemberLite } from '../../../../components/group/GroupMember.vue'
defineOptions({ name: 'ImMessageHistory' })

View File

@ -238,8 +238,8 @@ import { useImUiStore } from '../../../../store/uiStore'
import { useMessageSender } from '../../../../composables/useMessageSender'
import type { Message } from '../../../../types'
import MessageReadStatus from './MessageReadStatus.vue'
import UserAvatar from '../../../../components/UserAvatar.vue'
import type { GroupMemberLite } from '../../../../components/GroupMember.vue'
import UserAvatar from '../../../../components/user/UserAvatar.vue'
import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue'
defineOptions({ name: 'ImMessageItem' })

View File

@ -123,9 +123,8 @@ import MessageInput from '../input/MessageInput.vue'
import MessageHistory from './MessageHistory.vue'
import ConversationGroupSide from '../conversation/ConversationGroupSide.vue'
import ConversationPrivateSide from '../conversation/ConversationPrivateSide.vue'
import type { GroupLite } from '../../../group/components/GroupItem.vue'
import type { GroupMemberLite } from '../../../../components/GroupMember.vue'
import type { FriendLite } from '../../../friend/components/FriendItem.vue'
import type { FriendLite, GroupLite } from '../../../../types'
import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue'
defineOptions({ name: 'ImMessagePanel' })

View File

@ -60,7 +60,7 @@ import { CommonStatusEnum } from '@/utils/constants'
import { ImConversationType, ImGroupReceiptStatus } from '../../../../../utils/constants'
import type { Message } from '../../../../types'
import { useConversationStore } from '../../../../store/conversationStore'
import GroupMember, { type GroupMemberLite } from '../../../../components/GroupMember.vue'
import GroupMember, { type GroupMemberLite } from '../../../../components/group/GroupMember.vue'
import PagedScroller from '../../../../components/PagedScroller.vue'
defineOptions({ name: 'ImMessageReadStatus' })

View File

@ -51,8 +51,8 @@
<MessagePanel />
<!-- 添加朋友 / 发起群聊弹窗 -->
<AddFriendDialog v-model="addFriendVisible" />
<CreateGroupDialog
<FriendAddDialog v-model="addFriendVisible" />
<GroupCreateDialog
v-model="createGroupVisible"
:friends="friends"
@created="handleGroupCreated"
@ -69,13 +69,12 @@ import { useGroupStore } from '../../store/groupStore'
import { StorageKeys } from '../../../utils/storage'
import { ImConversationType } from '../../../utils/constants'
import { CommonStatusEnum } from '@/utils/constants'
import type { Friend } from '../../types'
import type { FriendLite } from '../friend/components/FriendItem.vue'
import type { Friend, FriendLite } from '../../types'
import ResizableAside from '../../components/ResizableAside.vue'
import ConversationItem from './components/conversation/ConversationItem.vue'
import MessagePanel from './components/message/MessagePanel.vue'
import AddFriendDialog from '../friend/components/AddFriendDialog.vue'
import CreateGroupDialog from '../group/components/CreateGroupDialog.vue'
import FriendAddDialog from '../../components/friend/FriendAddDialog.vue'
import GroupCreateDialog from '../../components/group/GroupCreateDialog.vue'
defineOptions({ name: 'ImMessagePage' })
@ -100,7 +99,7 @@ const filteredConversations = computed(() => {
)
})
/** CreateGroupDialog 需要全量好友列表来勾选成员,结构与 friend / group Tab 保持一致 */
/** GroupCreateDialog 需要全量好友列表来勾选成员,结构与通讯录里好友/群分组保持一致 */
const friends = computed<FriendLite[]>(() =>
friendStore.getActiveFriends.map((friend: Friend) => ({
id: friend.friendUserId,
@ -112,7 +111,7 @@ const friends = computed<FriendLite[]>(() =>
/** 处理建群成功 */
function handleGroupCreated(groupId: number) {
// CreateGroupDialog upsertGroup store get +
// GroupCreateDialog upsertGroup store get +
const group = groupStore.getGroup(groupId)
if (!group) {
return

View File

@ -1,22 +0,0 @@
<template>
<!-- 好友 Tab 占位页对齐 boxim /home/friend后续接入好友申请分组黑名单等功能 -->
<div class="im-placeholder-page">
<el-empty description="好友功能敬请期待" :image-size="120" />
</div>
</template>
<script lang="ts" setup>
defineOptions({ name: 'ImFriendPage' })
</script>
<style scoped>
.im-placeholder-page {
display: flex;
flex: 1;
min-width: 0;
height: 100%;
align-items: center;
justify-content: center;
background-color: #f5f5f5;
}
</style>

View File

@ -1,129 +0,0 @@
<template>
<!--
好友单行项对应 boxim friend/FriendItem.vue
- 头像 + 昵称 + 在线标识
- 选中态 active
- 右键菜单发消息 / 删除好友由全局 ContextMenu 承接
-->
<div
class="im-friend-item"
:class="{ 'is-active': active }"
@click="$emit('click', friend)"
@contextmenu.prevent="handleContextMenu"
>
<UserAvatar
:id="friend.id"
:url="friend.headImage"
:name="friend.nickName"
:online="friend.online"
:size="42"
:clickable="false"
/>
<div class="im-friend-item__info">
<div class="im-friend-item__name">{{ friend.nickName }}</div>
<div class="im-friend-item__online">
<span v-if="friend.onlineWeb" class="im-friend-item__dot" title="Web 在线">💻</span>
<span v-if="friend.onlineApp" class="im-friend-item__dot" title="App 在线">📱</span>
</div>
</div>
<slot></slot>
</div>
</template>
<script lang="ts" setup>
import { useImUiStore } from '../../../store/uiStore'
import UserAvatar from '../../../components/UserAvatar.vue'
defineOptions({ name: 'ImFriendItem' })
export interface FriendLite {
id: string | number
nickName: string
headImage?: string
online?: boolean
onlineWeb?: boolean
onlineApp?: boolean
deleted?: boolean
}
const props = withDefaults(
defineProps<{
friend: FriendLite
active?: boolean
/** 是否启用右键菜单;在选择器弹窗里一般关闭 */
menu?: boolean
}>(),
{
active: false,
menu: true
}
)
const emit = defineEmits<{
click: [friend: FriendLite]
chat: [friend: FriendLite]
delete: [friend: FriendLite]
}>()
const uiStore = useImUiStore()
function handleContextMenu(e: MouseEvent) {
if (!props.menu) return
uiStore.openContextMenu(
{ x: e.clientX, y: e.clientY },
[
{ key: 'chat', name: '发送消息' },
{ key: 'delete', name: '删除好友' }
],
(item) => {
if (item.key === 'chat') emit('chat', props.friend)
else if (item.key === 'delete') emit('delete', props.friend)
}
)
}
</script>
<style scoped>
.im-friend-item {
position: relative;
display: flex;
gap: 10px;
align-items: center;
height: 50px;
margin: 0 3px;
padding: 5px 8px;
cursor: pointer;
white-space: nowrap;
border-radius: 10px;
transition: background-color 0.15s;
}
.im-friend-item:hover {
background-color: #f5f7fa;
}
.im-friend-item.is-active {
background-color: #e1eaf7;
}
.im-friend-item__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.im-friend-item__name {
overflow: hidden;
font-size: 14px;
color: #303133;
text-overflow: ellipsis;
}
.im-friend-item__online {
display: flex;
gap: 4px;
font-size: 12px;
}
</style>

View File

@ -1,22 +0,0 @@
<template>
<!-- 群聊 Tab 占位页对齐 boxim /home/group后续接入我的群创建群群管理等功能 -->
<div class="im-placeholder-page">
<el-empty description="群聊功能敬请期待" :image-size="120" />
</div>
</template>
<script lang="ts" setup>
defineOptions({ name: 'ImGroupPage' })
</script>
<style scoped>
.im-placeholder-page {
display: flex;
flex: 1;
min-width: 0;
height: 100%;
align-items: center;
justify-content: center;
background-color: #f5f5f5;
}
</style>

View File

@ -1,96 +0,0 @@
<template>
<!--
群单行项对应 boxim group/GroupItem.vue
- 头像 + 群名
- 选中态 active
-->
<div
class="im-group-item"
:class="{ 'is-active': active }"
@click="$emit('click', group)"
>
<UserAvatar
:url="group.headImage || group.headImageThumb"
:name="group.showGroupName || group.name"
:size="42"
:clickable="false"
/>
<div class="im-group-item__info">
<div class="im-group-item__name">{{ group.showGroupName || group.name }}</div>
<div v-if="group.memberCount != null" class="im-group-item__desc">
{{ group.memberCount }} 位成员
</div>
</div>
<slot></slot>
</div>
</template>
<script lang="ts" setup>
import UserAvatar from '../../../components/UserAvatar.vue'
defineOptions({ name: 'ImGroupItem' })
export interface GroupLite {
id: string | number
name?: string
/** 带备注的展示名(如"我在群里的昵称" */
showGroupName?: string
headImage?: string
headImageThumb?: string
memberCount?: number
ownerId?: string | number
}
defineProps<{
group: GroupLite
active?: boolean
}>()
defineEmits<{
click: [group: GroupLite]
}>()
</script>
<style scoped>
.im-group-item {
position: relative;
display: flex;
gap: 10px;
align-items: center;
height: 50px;
margin: 0 3px;
padding: 5px 8px;
cursor: pointer;
white-space: nowrap;
border-radius: 10px;
transition: background-color 0.15s;
}
.im-group-item:hover {
background-color: #f5f7fa;
}
.im-group-item.is-active {
background-color: #e1eaf7;
}
.im-group-item__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.im-group-item__name {
overflow: hidden;
font-size: 14px;
color: #303133;
text-overflow: ellipsis;
}
.im-group-item__desc {
font-size: 12px;
color: #909399;
}
</style>

View File

@ -1,52 +0,0 @@
<template>
<!--
群成员宫格单元对应 boxim group/GroupMember.vue
- 宫格展示的最小单位50px 窄列头像在上名字在下
- GroupMemberSelector 右侧已选区ChatGroupSide 群成员区循环使用
-->
<div class="im-group-member-grid">
<UserAvatar
:id="member.userId"
:url="member.headImage"
:name="member.showNickName"
:size="38"
:clickable="false"
/>
<div class="im-group-member-grid__name">{{ member.showNickName }}</div>
<slot></slot>
</div>
</template>
<script lang="ts" setup>
import UserAvatar from '../../../components/UserAvatar.vue'
import type { GroupMemberLite } from '../../chat/components/ChatGroupMember.vue'
defineOptions({ name: 'ImGroupMemberGrid' })
defineProps<{
member: GroupMemberLite
}>()
</script>
<style scoped>
.im-group-member-grid {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 54px;
padding: 4px 2px;
}
.im-group-member-grid__name {
width: 100%;
margin-top: 2px;
overflow: hidden;
font-size: 12px;
line-height: 18px;
color: #606266;
text-align: center;
white-space: nowrap;
text-overflow: ellipsis;
}
</style>

View File

@ -1,87 +0,0 @@
<template>
<!--
群成员行形态对应 boxim group/GroupMemberItem.vue
- 横排 hover slot checkbox / 操作按钮等
- ChatGroupMember 的差别 hover + slot 扩展点适合 selector / admin 列表
-->
<div
class="im-group-member-item"
:class="{ 'is-active': active }"
:style="{ height: height + 'px' }"
@click="$emit('click', member)"
>
<UserAvatar
:id="member.userId"
:url="member.headImage"
:name="member.showNickName"
:size="avatarSize"
:clickable="false"
/>
<div class="im-group-member-item__name" :style="{ lineHeight: height + 'px' }">
{{ member.showNickName }}
</div>
<slot></slot>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import UserAvatar from '../../../components/UserAvatar.vue'
import type { GroupMemberLite } from '../../chat/components/ChatGroupMember.vue'
defineOptions({ name: 'ImGroupMemberItem' })
const props = withDefaults(
defineProps<{
member: GroupMemberLite
height?: number
active?: boolean
}>(),
{
height: 50,
active: false
}
)
defineEmits<{
click: [member: GroupMemberLite]
}>()
const avatarSize = computed(() => Math.ceil(props.height * 0.75))
</script>
<style scoped>
.im-group-member-item {
position: relative;
display: flex;
gap: 10px;
align-items: center;
margin: 0 1px;
padding: 0 15px;
box-sizing: border-box;
white-space: nowrap;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.15s;
}
.im-group-member-item:hover {
background-color: #f5f7fa;
}
.im-group-member-item.is-active {
background-color: #e1eaf7;
}
.im-group-member-item__name {
flex: 1;
height: 100%;
padding-left: 4px;
overflow: hidden;
font-size: 14px;
color: #303133;
white-space: nowrap;
text-overflow: ellipsis;
}
</style>

View File

@ -1,7 +1,7 @@
import { defineStore, acceptHMRUpdate } from 'pinia'
import { reactive } from 'vue'
import type { UserInfo } from '../types'
import type { User } from '../types'
/**
* IM UI store
@ -15,12 +15,12 @@ export const useImUiStore = defineStore('imUiStore', () => {
// 用户名片悬浮卡:头像 / 昵称等触发点遍布会话、群成员、@ 选择器等列表,
const userInfoCard = reactive({
show: false,
user: null as UserInfo | null,
user: null as User | null,
position: { x: 0, y: 0 }
})
/** 打开用户名片 */
function openUserInfoCard(user: UserInfo, position: { x: number; y: number }) {
function openUserInfoCard(user: User, position: { x: number; y: number }) {
const viewportWidth = document.documentElement.clientWidth
const viewportHeight = document.documentElement.clientHeight
userInfoCard.user = user

View File

@ -156,7 +156,7 @@ export interface Friend {
// ==================== 用户名片 ====================
// 用户精简信息(对齐后端 UserSimpleRespVO名片 / 头像 hover 等场景共用)
export interface UserInfo {
export interface User {
id: number
nickname?: string
avatar?: string
@ -164,3 +164,33 @@ export interface UserInfo {
deptId?: number
deptName?: string
}
// ==================== 列表行展示用 Lite 类型 ====================
/**
* Friend
* - id friendUserId click / Friend.id
* - deleted Friend.status === DISABLE
*/
export interface FriendLite {
id: number
nickname: string
avatar?: string
displayName?: string
deleted?: boolean
}
/**
* Group
* - showGroupName / showImage show* >
* - showImageThumb
*/
export interface GroupLite {
id: number
name?: string
showGroupName?: string
showImage?: string
showImageThumb?: string
memberCount?: number
ownerId?: number
}