✨ feat(im): 优化整体包结构,将 friend、group 通用组件抽过去。
parent
4b64153044
commit
a762dfff84
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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(',') }
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
// IM 数据看板 API
|
||||
//
|
||||
// vibe 阶段:所有方法用本地 mock(setTimeout + 随机数据)实现,不发真实请求。
|
||||
// 后端就绪后把 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' })
|
||||
}
|
||||
|
|
@ -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: '数据看板'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 而非 path:path 后期容易变(前缀调整、嵌套加层),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 */
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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' })
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
|
|
@ -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 图标;陌生人 = 加为好友按钮;自己 = disabled;readonly 不渲染 -->
|
||||
<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 / 女 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 ''
|
||||
})
|
||||
|
||||
/** 备注内联编辑: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 里?
|
||||
非 scoped:el-dropdown 的下拉菜单走 teleport 到 body,scoped 选不到。
|
||||
UserInfoCard 浮层用 z-9998,要把这块抬到更高(默认 --el-popper-z-index ~2050 会被遮罩压住)。
|
||||
-->
|
||||
<style>
|
||||
.im-user-info__more-menu {
|
||||
z-index: 10000 !important;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue