✨ feat(im): 增加群消息的置顶
parent
ffb69063b9
commit
01e0e8e37b
|
|
@ -1,4 +1,5 @@
|
|||
import request from '@/config/axios'
|
||||
import type { ImGroupMessageRespVO } from '@/api/im/message/group'
|
||||
|
||||
// 群 Response VO
|
||||
export interface ImGroupRespVO {
|
||||
|
|
@ -12,6 +13,14 @@ export interface ImGroupRespVO {
|
|||
status: number // 群状态(0=正常,1=已解散)
|
||||
dissolvedTime?: string // 解散时间
|
||||
createTime?: string // 创建时间
|
||||
// TODO @AI:不太对,返回的就是 ImGroupMessageRespVO 数组
|
||||
pinnedMessages?: ImGroupMessageRespVO[] // 群置顶消息列表(后端关联回填,仅当登录用户是群成员时非空)
|
||||
}
|
||||
|
||||
// 群消息置顶 / 取消置顶 Request VO
|
||||
export interface ImGroupMessagePinReqVO {
|
||||
groupId: number // 群编号
|
||||
messageId: number // 消息编号
|
||||
}
|
||||
|
||||
// 群创建 Request VO
|
||||
|
|
@ -79,3 +88,13 @@ export const removeGroupAdmin = (data: ImGroupAdminReqVO) => {
|
|||
export const transferGroupOwner = (data: ImGroupTransferOwnerReqVO) => {
|
||||
return request.put<boolean>({ url: '/im/group/transfer-owner', data })
|
||||
}
|
||||
|
||||
// 置顶群消息(仅群主 / 管理员可调)
|
||||
export const pinGroupMessage = (data: ImGroupMessagePinReqVO) => {
|
||||
return request.put<boolean>({ url: '/im/group/pin-message', data })
|
||||
}
|
||||
|
||||
// 取消置顶群消息(仅群主 / 管理员可调)
|
||||
export const unpinGroupMessage = (data: ImGroupMessagePinReqVO) => {
|
||||
return request.put<boolean>({ url: '/im/group/unpin-message', data })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -237,6 +237,9 @@
|
|||
|
||||
<!-- ==================== 查找聊天内容 ==================== -->
|
||||
<!-- 点击 → 父组件打开 MessageHistory 弹窗 -->
|
||||
|
||||
<div class="im-conversation-group-side__spacer"></div>
|
||||
|
||||
<div class="im-conversation-group-side__section">
|
||||
<div
|
||||
class="im-conversation-group-side__row im-conversation-group-side__row--clickable"
|
||||
|
|
@ -356,15 +359,16 @@ import { useMessage } from '@/hooks/web/useMessage'
|
|||
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { CommonStatusEnum } from '@/utils/constants'
|
||||
import { updateGroup, addGroupAdmin, removeGroupAdmin, transferGroupOwner } from '@/api/im/group'
|
||||
import {
|
||||
updateGroup,
|
||||
addGroupAdmin,
|
||||
removeGroupAdmin,
|
||||
transferGroupOwner
|
||||
} from '@/api/im/group'
|
||||
import { quitGroup, removeGroupMember, updateGroupMember } from '@/api/im/group/member'
|
||||
import { useConversationStore } from '../../../../store/conversationStore'
|
||||
import { useGroupStore } from '../../../../store/groupStore'
|
||||
import {
|
||||
GROUP_ADMIN_MAX_COUNT,
|
||||
ImConversationType,
|
||||
ImGroupMemberRole
|
||||
} from '@/views/im/utils/constants'
|
||||
import { GROUP_ADMIN_MAX_COUNT, ImConversationType, ImGroupMemberRole } from '@/views/im/utils/constants'
|
||||
import GroupMemberGrid from '../../../../components/group/GroupMemberGrid.vue'
|
||||
import GroupMemberAddDialog from '../../../../components/group/GroupMemberAddDialog.vue'
|
||||
import GroupMemberSelector, {
|
||||
|
|
@ -462,6 +466,7 @@ const isOwnerOrAdmin = computed(
|
|||
() => myRole.value === ImGroupMemberRole.OWNER || myRole.value === ImGroupMemberRole.ADMIN
|
||||
)
|
||||
|
||||
|
||||
// 排除已退群成员 + 关键字过滤;按角色排序:群主→管理员→普通成员(同角色按 userId 稳定)
|
||||
const visibleMembers = computed(() => {
|
||||
return props.members
|
||||
|
|
|
|||
|
|
@ -0,0 +1,251 @@
|
|||
<template>
|
||||
<!-- 群聊置顶消息:仅群聊 + 有置顶时显示,悬挂在群聊头部下方左上角;不占整行(对齐微信 PC) -->
|
||||
<div v-if="pinnedMessages.length > 0" class="im-conversation-group-pinned">
|
||||
<!-- 顶部胶囊:单条点击跳转;多条折叠点击展开;多条展开点击折叠 -->
|
||||
<div
|
||||
class="im-conversation-group-pinned__row im-conversation-group-pinned__row--clickable"
|
||||
@click="handleTopClick"
|
||||
>
|
||||
<Icon icon="ant-design:pushpin-outlined" :size="14" class="im-conversation-group-pinned__icon" />
|
||||
<span class="im-conversation-group-pinned__sender">{{ getSenderName(latest) }}:</span>
|
||||
<span class="im-conversation-group-pinned__text">{{ getPreview(latest) }}</span>
|
||||
<!-- 单条:移除按钮;多条折叠:共 N 条;多条展开:收起箭头 -->
|
||||
<span
|
||||
v-if="pinnedMessages.length === 1 && canManage"
|
||||
v-loading="removingId === latest.id"
|
||||
class="im-conversation-group-pinned__remove"
|
||||
@click.stop="handleRemove(latest)"
|
||||
>
|
||||
移除
|
||||
</span>
|
||||
<template v-else-if="pinnedMessages.length > 1">
|
||||
<span class="im-conversation-group-pinned__count">共 {{ pinnedMessages.length }} 条</span>
|
||||
<Icon
|
||||
:icon="expanded ? 'ant-design:up-outlined' : 'ant-design:down-outlined'"
|
||||
:size="11"
|
||||
class="im-conversation-group-pinned__chevron"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 多条展开:浅色面板包裹完整列表,每条独立胶囊;点击跳转到对应消息位置 -->
|
||||
<div v-if="pinnedMessages.length > 1 && expanded" class="im-conversation-group-pinned__list">
|
||||
<div
|
||||
v-for="msg in pinnedMessages"
|
||||
:key="msg.id"
|
||||
class="im-conversation-group-pinned__row im-conversation-group-pinned__row--list im-conversation-group-pinned__row--clickable"
|
||||
@click="handleLocate(msg)"
|
||||
>
|
||||
<Icon icon="ant-design:pushpin-outlined" :size="14" class="im-conversation-group-pinned__icon" />
|
||||
<span class="im-conversation-group-pinned__sender">{{ getSenderName(msg) }}:</span>
|
||||
<span class="im-conversation-group-pinned__text">{{ getPreview(msg) }}</span>
|
||||
<span
|
||||
v-if="canManage"
|
||||
v-loading="removingId === msg.id"
|
||||
class="im-conversation-group-pinned__remove"
|
||||
@click.stop="handleRemove(msg)"
|
||||
>
|
||||
移除
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import Icon from '@/components/Icon/src/Icon.vue'
|
||||
import { useMessage } from '@/hooks/web/useMessage'
|
||||
import { ImConversationType, ImGroupMemberRole } from '@/views/im/utils/constants'
|
||||
import { unpinGroupMessage as apiUnpinGroupMessage } from '@/api/im/group'
|
||||
import { getSenderDisplayName } from '@/views/im/utils/user'
|
||||
import { resolveConversationLastContent } from '@/views/im/utils/conversation'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { useGroupStore } from '../../../../store/groupStore'
|
||||
import type { Message } from '../../../../types'
|
||||
|
||||
defineOptions({ name: 'ImConversationGroupPinned' })
|
||||
|
||||
const props = defineProps<{
|
||||
/** 当前群编号(自行从 groupStore 拿完整 Group,跟随响应式) */
|
||||
groupId: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 点击置顶消息 → 父级 MessagePanel 滚动定位到原消息位置 */
|
||||
locate: [messageId: number]
|
||||
}>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
const groupStore = useGroupStore()
|
||||
const message = useMessage()
|
||||
|
||||
/** 当前群(含 pinnedMessages) */
|
||||
const group = computed(() => groupStore.getGroup(props.groupId))
|
||||
|
||||
const expanded = ref(false)
|
||||
const removingId = ref<number | null>(null)
|
||||
|
||||
/** 当前群置顶消息列表(直接走 group.value,跟随响应式) */
|
||||
const pinnedMessages = computed<Message[]>(() => group.value?.pinnedMessages ?? [])
|
||||
|
||||
/** 顶部胶囊展示的最新一条(即列表最后一条,pin 顺序追加) */
|
||||
const latest = computed(() => pinnedMessages.value[pinnedMessages.value.length - 1])
|
||||
|
||||
/** 当前用户是否群主 / 管理员(决定是否显示「移除」入口) */
|
||||
const canManage = computed(() => {
|
||||
const myId = Number(userStore.getUser?.id) || 0
|
||||
const role = group.value?.members?.find((m) => m.userId === myId)?.role
|
||||
return role === ImGroupMemberRole.OWNER || role === ImGroupMemberRole.ADMIN
|
||||
})
|
||||
|
||||
/** 顶部胶囊点击:单条直接跳转原消息位置;多条切换展开 / 折叠 */
|
||||
function handleTopClick() {
|
||||
if (pinnedMessages.value.length === 1) {
|
||||
handleLocate(latest.value)
|
||||
return
|
||||
}
|
||||
expanded.value = !expanded.value
|
||||
}
|
||||
|
||||
/** 点击置顶消息行 → 触发跳转 + 收起弹出层 */
|
||||
function handleLocate(msg: Message) {
|
||||
emit('locate', msg.id)
|
||||
expanded.value = false
|
||||
}
|
||||
|
||||
/** 置顶消息发送人显示名 */
|
||||
function getSenderName(msg: Message): string {
|
||||
return group.value ? getSenderDisplayName(msg.senderId, ImConversationType.GROUP, group.value.id) : ''
|
||||
}
|
||||
|
||||
/** 置顶消息预览文本:复用会话最后一条摘要逻辑([图片] / [文件] / 文本等) */
|
||||
function getPreview(msg: Message): string {
|
||||
return group.value ? resolveConversationLastContent(msg, ImConversationType.GROUP, group.value.id) : ''
|
||||
}
|
||||
|
||||
/** 移除置顶:调后端 API,loading 期间禁止重复点;后端广播 GROUP_MESSAGE_UNPIN 由 dispatcher 自动同步本地 */
|
||||
async function handleRemove(msg: Message) {
|
||||
if (!group.value || removingId.value !== null) {
|
||||
return
|
||||
}
|
||||
removingId.value = msg.id
|
||||
try {
|
||||
await apiUnpinGroupMessage({ groupId: group.value.id, messageId: msg.id })
|
||||
message.success('已取消置顶')
|
||||
} finally {
|
||||
removingId.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 容器:左对齐悬浮在 header 下方;不占整行;relative 让展开列表绝对定位贴顶部胶囊下方 */
|
||||
.im-conversation-group-pinned {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 6px 16px 8px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
/* 列表面板:绝对定位悬浮在顶部胶囊正下方;白色背景 + 强阴影跟 header 浅灰对比,呈现卡片层级感
|
||||
margin-top: -1px 让弹出层往上盖住 header bottom 那 1px 分隔线,避免视觉上有横向空隙 */
|
||||
.im-conversation-group-pinned__list {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
margin-top: -1px;
|
||||
left: 6px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 380px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background-color: var(--el-bg-color);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
/* 弹出层箭头:朝上的三角,颜色跟弹出层 background 一致(白色),跟 header 浅灰强对比 */
|
||||
.im-conversation-group-pinned__list::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 184px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 8px solid transparent;
|
||||
border-right: 8px solid transparent;
|
||||
border-bottom: 8px solid var(--el-bg-color);
|
||||
filter: drop-shadow(0 -2px 1px rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
|
||||
/* 胶囊基础样式:顶部固定 width;弹出层里的胶囊撑满外框宽度 */
|
||||
.im-conversation-group-pinned__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 360px;
|
||||
padding: 6px 12px;
|
||||
background-color: var(--el-bg-color);
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-primary);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.im-conversation-group-pinned__row--clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.im-conversation-group-pinned__row--clickable:hover {
|
||||
background-color: var(--el-fill-color-lighter);
|
||||
}
|
||||
/* 列表里的胶囊:撑满弹出层宽度;浅灰背景跟弹出层白色区分,hover 反相白色 */
|
||||
.im-conversation-group-pinned__row--list {
|
||||
width: 100%;
|
||||
background-color: var(--el-fill-color-light);
|
||||
box-shadow: none;
|
||||
}
|
||||
.im-conversation-group-pinned__row--list:hover {
|
||||
background-color: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.im-conversation-group-pinned__icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
|
||||
.im-conversation-group-pinned__sender {
|
||||
flex-shrink: 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.im-conversation-group-pinned__text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.im-conversation-group-pinned__count {
|
||||
flex-shrink: 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.im-conversation-group-pinned__chevron {
|
||||
flex-shrink: 0;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.im-conversation-group-pinned__remove {
|
||||
flex-shrink: 0;
|
||||
color: var(--el-color-primary);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.im-conversation-group-pinned__remove:hover {
|
||||
color: var(--el-color-primary-light-3);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -228,8 +228,11 @@ import {
|
|||
ImMessageStatus,
|
||||
ImGroupReceiptStatus,
|
||||
ImConversationType,
|
||||
isGroupNotification
|
||||
} from '../../../../../utils/constants'
|
||||
ImGroupMemberRole,
|
||||
isGroupNotification,
|
||||
isNormalMessage
|
||||
} from '@/views/im/utils/constants'
|
||||
import { pinGroupMessage as apiPinGroupMessage } from '@/api/im/group'
|
||||
import {
|
||||
buildQuoteFromMessage,
|
||||
getQuoteFromMessage,
|
||||
|
|
@ -241,7 +244,7 @@ import {
|
|||
type FileMessage,
|
||||
type AudioMessage,
|
||||
type VideoMessage
|
||||
} from '../../../../../utils/message'
|
||||
} from '@/views/im/utils/message'
|
||||
import { buildRecallTip } from '../../../../../utils/conversation'
|
||||
import { formatSeconds } from '@/utils/formatTime'
|
||||
import { formatFileSize } from '@/utils/file'
|
||||
|
|
@ -255,7 +258,7 @@ import {
|
|||
getSenderDisplayName,
|
||||
getSenderRealNickname,
|
||||
resolveGroupNotificationText
|
||||
} from '../../../../../utils/user'
|
||||
} from '@/views/im/utils/user'
|
||||
import { useImUiStore } from '../../../../store/uiStore'
|
||||
import { useMessageSender } from '../../../../composables/useMessageSender'
|
||||
import type { Message } from '../../../../types'
|
||||
|
|
@ -264,6 +267,8 @@ import ReplyPreview from './ReplyPreview.vue'
|
|||
import UserAvatar from '../../../../components/user/UserAvatar.vue'
|
||||
import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue'
|
||||
|
||||
// TODO @AI:参考 /Users/yunai/Java/yudao-all-in-im/yudao-ui-admin-vue3/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue 做下分块?
|
||||
|
||||
defineOptions({ name: 'ImMessageItem' })
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -283,7 +288,7 @@ const draftStore = useDraftStore()
|
|||
const uiStore = useImUiStore()
|
||||
const { recall, sendRaw } = useMessageSender()
|
||||
// 仅用 confirm,避免 message 跟 props.message 同名冲突(vue/no-dupe-keys)
|
||||
const { confirm: confirmDialog } = useMessage()
|
||||
const { confirm: confirmDialog, success: successMessage } = useMessage()
|
||||
|
||||
/** 是否已撤回:pull / WS 两路都会调 recallMessage 把原消息更新为 type=RECALL,渲染只需识别 type */
|
||||
const isRecall = computed(() => props.message.type === ImMessageType.RECALL)
|
||||
|
|
@ -516,6 +521,7 @@ const isAtMe = computed(() => {
|
|||
/** 右键菜单 key 常量;push 端和分发端从同一处取,typo 编译期就能抓 */
|
||||
const MENU_KEYS = {
|
||||
REPLY: 'REPLY',
|
||||
PIN: 'PIN',
|
||||
RECALL: 'RECALL',
|
||||
DELETE: 'DELETE'
|
||||
} as const
|
||||
|
|
@ -552,6 +558,15 @@ async function handleContextMenu(e: MouseEvent) {
|
|||
icon: 'bxs:quote-alt-left'
|
||||
})
|
||||
}
|
||||
// 「置顶」:仅群聊 + 普通消息 + 已落库 + 未撤回 + 群主或管理员;已置顶不再展示,由置顶面板的「移除」入口承接
|
||||
// 不本地预判上限,让后端校验,超限时通过 error toast 反馈
|
||||
if (canPin.value) {
|
||||
items.push({
|
||||
key: MENU_KEYS.PIN,
|
||||
name: '置顶',
|
||||
icon: 'ant-design:pushpin-outlined'
|
||||
})
|
||||
}
|
||||
// 「撤回 / 删除」二选一:
|
||||
// - 自己发送 + 已落库(id≠0)+ 未撤回 + 在撤回窗口内 → 撤回(推服务器把消息态置 RECALL)
|
||||
// - 其它(对方消息 / 已撤回 / 超出撤回窗口)→ 删除(仅本地清,不动后端)
|
||||
|
|
@ -583,6 +598,8 @@ async function handleContextMenu(e: MouseEvent) {
|
|||
uiStore.openContextMenu({ x: e.clientX, y: e.clientY }, items, async (item) => {
|
||||
if (item.key === MENU_KEYS.REPLY) {
|
||||
handleReply()
|
||||
} else if (item.key === MENU_KEYS.PIN) {
|
||||
await handlePin()
|
||||
} else if (item.key === MENU_KEYS.RECALL) {
|
||||
await handleRecall()
|
||||
} else if (item.key === MENU_KEYS.DELETE) {
|
||||
|
|
@ -591,6 +608,45 @@ async function handleContextMenu(e: MouseEvent) {
|
|||
})
|
||||
}
|
||||
|
||||
/** 当前激活会话对应的群(私聊场景为 undefined) */
|
||||
const currentGroup = computed(() => {
|
||||
const conversation = conversationStore.activeConversation
|
||||
if (!conversation || conversation.type !== ImConversationType.GROUP) {
|
||||
return undefined
|
||||
}
|
||||
return groupStore.getGroup(conversation.targetId)
|
||||
})
|
||||
|
||||
/** 当前用户在该群里的角色;私聊或非群成员 → undefined */
|
||||
const myGroupRole = computed(() => {
|
||||
const myId = Number(userStore.getUser?.id) || 0
|
||||
return currentGroup.value?.members?.find((m) => m.userId === myId)?.role
|
||||
})
|
||||
|
||||
/** 是否允许置顶(已置顶消息不再展示菜单项,由置顶面板的「移除」入口承接):群聊 + 普通消息 + 已落库 + 未撤回 + 群主或管理员 + 未置顶 */
|
||||
const canPin = computed(
|
||||
() =>
|
||||
!!currentGroup.value &&
|
||||
isNormalMessage(props.message.type) &&
|
||||
!!props.message.id &&
|
||||
!isRecall.value &&
|
||||
(myGroupRole.value === ImGroupMemberRole.OWNER || myGroupRole.value === ImGroupMemberRole.ADMIN) &&
|
||||
!currentGroup.value.pinnedMessages?.some((m) => m.id === props.message.id)
|
||||
)
|
||||
|
||||
/** 置顶消息:二次确认 → 调后端 pin-message;后端广播 GROUP_MESSAGE_PIN,本端 dispatcher 拉最新 pinnedMessages */
|
||||
async function handlePin() {
|
||||
const group = currentGroup.value
|
||||
if (!group) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await confirmDialog('将在当前群成员的聊天中置顶', '置顶消息', { confirmButtonText: '置顶' })
|
||||
await apiPinGroupMessage({ groupId: group.id, messageId: props.message.id })
|
||||
successMessage('已置顶')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 进入引用模式:把当前消息构造成 QuoteMessage 写入 draftStore,MessageInput 顶部引用条响应式出现 */
|
||||
function handleReply() {
|
||||
const conversation = conversationStore.activeConversation
|
||||
|
|
|
|||
|
|
@ -1,61 +1,68 @@
|
|||
<template>
|
||||
<div class="flex flex-1 flex-col min-w-0 bg-[var(--el-fill-color-light)]">
|
||||
<template v-if="conversationStore.activeConversation">
|
||||
<!-- 顶部:会话名(群聊带人数)+ 右侧功能图标;border 走 scoped CSS(项目 UnoCSS 不带 border-style preflight) -->
|
||||
<div
|
||||
class="message-panel__header flex items-center justify-between h-14 px-5 bg-[var(--el-fill-color-light)]"
|
||||
>
|
||||
<span class="flex flex-col min-w-0">
|
||||
<span class="flex items-baseline gap-1.5 min-w-0">
|
||||
<span
|
||||
class="overflow-hidden text-base font-medium truncate text-[var(--el-text-color-primary)]"
|
||||
>
|
||||
{{ conversationStore.activeConversation?.name || '' }}
|
||||
<!-- 顶部 header:第一行群名 + 右侧图标,第二行嵌入置顶气泡(仅群聊 + 有置顶);border 走 scoped CSS -->
|
||||
<div class="message-panel__header flex flex-col bg-[var(--el-fill-color-light)]">
|
||||
<div class="flex items-center justify-between h-14 px-5">
|
||||
<span class="flex flex-col min-w-0">
|
||||
<span class="flex items-baseline gap-1.5 min-w-0">
|
||||
<span
|
||||
class="overflow-hidden text-base font-medium truncate text-[var(--el-text-color-primary)]"
|
||||
>
|
||||
{{ conversationStore.activeConversation?.name || '' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="isGroup && headerMemberCount > 0"
|
||||
class="flex-shrink-0 text-sm text-[var(--el-text-color-secondary)]"
|
||||
>
|
||||
({{ headerMemberCount }})
|
||||
</span>
|
||||
</span>
|
||||
<!-- 副标题:备注 ≠ 群名时展示原群名,提示用户当前看到的主名是自己设的备注 -->
|
||||
<span
|
||||
v-if="isGroup && headerMemberCount > 0"
|
||||
class="flex-shrink-0 text-sm text-[var(--el-text-color-secondary)]"
|
||||
v-if="headerSubtitle"
|
||||
class="overflow-hidden text-xs truncate text-[var(--el-text-color-secondary)]"
|
||||
>
|
||||
({{ headerMemberCount }})
|
||||
{{ headerSubtitle }}
|
||||
</span>
|
||||
</span>
|
||||
<!-- 副标题:备注 ≠ 群名时展示原群名,提示用户当前看到的主名是自己设的备注 -->
|
||||
<span
|
||||
v-if="headerSubtitle"
|
||||
class="overflow-hidden text-xs truncate text-[var(--el-text-color-secondary)]"
|
||||
>
|
||||
{{ headerSubtitle }}
|
||||
</span>
|
||||
</span>
|
||||
<div class="flex gap-3 items-center">
|
||||
<!-- 聊天历史 -->
|
||||
<el-tooltip content="聊天历史" placement="bottom">
|
||||
<Icon
|
||||
icon="ep:chat-dot-round"
|
||||
:size="20"
|
||||
class="message-panel__header-icon cursor-pointer"
|
||||
@click="historyVisible = true"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<!-- 通话入口:暂未开放,先放占位图标对齐微信 PC -->
|
||||
<el-tooltip content="通话" placement="bottom">
|
||||
<Icon
|
||||
icon="ant-design:phone-outlined"
|
||||
:size="20"
|
||||
class="message-panel__header-icon cursor-pointer"
|
||||
@click="handleCall"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<!-- 信息抽屉入口 -->
|
||||
<el-tooltip :content="isGroup ? '群聊信息' : '聊天信息'" placement="bottom">
|
||||
<Icon
|
||||
icon="ant-design:ellipsis-outlined"
|
||||
:size="20"
|
||||
class="message-panel__header-icon cursor-pointer"
|
||||
@click="toggleSide"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<div class="flex gap-3 items-center">
|
||||
<!-- 聊天历史 -->
|
||||
<el-tooltip content="聊天历史" placement="bottom">
|
||||
<Icon
|
||||
icon="ep:chat-dot-round"
|
||||
:size="20"
|
||||
class="message-panel__header-icon cursor-pointer"
|
||||
@click="historyVisible = true"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<!-- 通话入口:暂未开放,先放占位图标对齐微信 PC -->
|
||||
<el-tooltip content="通话" placement="bottom">
|
||||
<Icon
|
||||
icon="ant-design:phone-outlined"
|
||||
:size="20"
|
||||
class="message-panel__header-icon cursor-pointer"
|
||||
@click="handleCall"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<!-- 信息抽屉入口 -->
|
||||
<el-tooltip :content="isGroup ? '群聊信息' : '聊天信息'" placement="bottom">
|
||||
<Icon
|
||||
icon="ant-design:ellipsis-outlined"
|
||||
:size="20"
|
||||
class="message-panel__header-icon cursor-pointer"
|
||||
@click="toggleSide"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 群置顶消息:第二行嵌入 header;仅群聊 + 有置顶时显示 -->
|
||||
<ConversationGroupPinned
|
||||
v-if="isGroup && conversationStore.activeConversation"
|
||||
:group-id="conversationStore.activeConversation.targetId"
|
||||
@locate="handleLocate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 中间:消息列表 -->
|
||||
|
|
@ -147,6 +154,7 @@ import MessageItem from './MessageItem.vue'
|
|||
import MessageInput from '../input/MessageInput.vue'
|
||||
import MessageHistory from './MessageHistory.vue'
|
||||
import ConversationGroupSide from '../conversation/ConversationGroupSide.vue'
|
||||
import ConversationGroupPinned from './ConversationGroupPinned.vue'
|
||||
import ConversationPrivateSide from '../conversation/ConversationPrivateSide.vue'
|
||||
import type { FriendLite, GroupLite } from '../../../../types'
|
||||
import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue'
|
||||
|
|
@ -478,6 +486,7 @@ watch(
|
|||
<style scoped>
|
||||
/* 顶部分隔线:UnoCSS 不带 border-style preflight,class 写法只设色 / 宽不出线,走 scoped 显式 shorthand */
|
||||
.message-panel__header {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import {
|
|||
StorageKeys
|
||||
} from '../../utils/storage'
|
||||
import { getGroupDisplayName, type GroupNotificationPayload } from '../../utils/user'
|
||||
import type { Group, GroupMember } from '../types'
|
||||
import type { Group, GroupMember, Message } from '../types'
|
||||
|
||||
/**
|
||||
* fetchGroupMembers 并发去重表:同 groupId 同时进的请求共用一个 Promise
|
||||
|
|
@ -543,6 +543,12 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
case ImMessageType.GROUP_OWNER_TRANSFER:
|
||||
this.applyGroupOwnerTransferNotification(groupId, payload)
|
||||
break
|
||||
case ImMessageType.GROUP_MESSAGE_PIN:
|
||||
this.applyGroupMessagePinNotification(groupId, payload)
|
||||
break
|
||||
case ImMessageType.GROUP_MESSAGE_UNPIN:
|
||||
this.applyGroupMessageUnpinNotification(groupId, payload)
|
||||
break
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -629,6 +635,58 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
}
|
||||
},
|
||||
|
||||
/** 群消息置顶:从 payload 直接拿完整消息对象 push 到 pinnedMessages */
|
||||
applyGroupMessagePinNotification(groupId: number, payload: GroupNotificationPayload) {
|
||||
if (!payload.message) {
|
||||
return
|
||||
}
|
||||
const group = this.getGroup(groupId)
|
||||
if (!group) {
|
||||
return
|
||||
}
|
||||
// TODO @AI:可以直接使用 payload,简化这样设置。。。
|
||||
const message: Message = {
|
||||
id: payload.message.id,
|
||||
clientMessageId: payload.message.clientMessageId || '',
|
||||
type: payload.message.type,
|
||||
content: payload.message.content,
|
||||
status: payload.message.status,
|
||||
sendTime: new Date(payload.message.sendTime).getTime(),
|
||||
senderId: payload.message.senderId,
|
||||
targetId: payload.message.groupId,
|
||||
selfSend: payload.message.senderId === getCurrentUserId(),
|
||||
atUserIds: payload.message.atUserIds || [],
|
||||
receiverUserIds: payload.message.receiverUserIds || [],
|
||||
receiptStatus: payload.message.receiptStatus,
|
||||
readCount: payload.message.readCount
|
||||
}
|
||||
// 幂等:已存在同 messageId 不重复 push
|
||||
const existing = group.pinnedMessages || []
|
||||
if (existing.some((m) => m.id === message.id)) {
|
||||
return
|
||||
}
|
||||
group.pinnedMessages = [...existing, message]
|
||||
this.saveGroups()
|
||||
},
|
||||
|
||||
/** 群消息取消置顶:按 messageId 从本地置顶列表中移除 */
|
||||
applyGroupMessageUnpinNotification(groupId: number, payload: GroupNotificationPayload) {
|
||||
if (!payload.messageId) {
|
||||
return
|
||||
}
|
||||
const group = this.getGroup(groupId)
|
||||
if (!group?.pinnedMessages?.length) {
|
||||
return
|
||||
}
|
||||
// TODO @AI:不要用 next 这样的单词,大家不好理解。可以用 newXXXX 这样。其它地方也看看。
|
||||
const next = group.pinnedMessages.filter((m) => m.id !== payload.messageId)
|
||||
if (next.length === group.pinnedMessages.length) {
|
||||
return
|
||||
}
|
||||
group.pinnedMessages = next
|
||||
this.saveGroups()
|
||||
},
|
||||
|
||||
/** 切账号时仅清 in-memory,IDB 按 userId 分桶天然隔离,回切秒开 */
|
||||
clear() {
|
||||
this.groups = []
|
||||
|
|
@ -646,7 +704,28 @@ function convertGroup(vo: ImGroupRespVO): Group {
|
|||
name: vo.name,
|
||||
avatar: vo.avatar,
|
||||
notice: vo.notice,
|
||||
ownerUserId: vo.ownerUserId
|
||||
ownerUserId: vo.ownerUserId,
|
||||
pinnedMessages: vo.pinnedMessages?.map(convertGroupMessageVO)
|
||||
}
|
||||
}
|
||||
|
||||
/** 后端 ImGroupMessageRespVO -> 前端 Message:补 targetId / selfSend / sendTime 等派生字段 */
|
||||
function convertGroupMessageVO(message: NonNullable<ImGroupRespVO['pinnedMessages']>[number]): Message {
|
||||
const currentUserId = getCurrentUserId()
|
||||
return {
|
||||
id: message.id,
|
||||
clientMessageId: message.clientMessageId || '',
|
||||
type: message.type,
|
||||
content: message.content,
|
||||
status: message.status,
|
||||
sendTime: new Date(message.sendTime).getTime(),
|
||||
senderId: message.senderId,
|
||||
targetId: message.groupId,
|
||||
selfSend: !!currentUserId && message.senderId === currentUserId,
|
||||
atUserIds: message.atUserIds || [],
|
||||
receiverUserIds: message.receiverUserIds || [],
|
||||
receiptStatus: message.receiptStatus,
|
||||
readCount: message.readCount
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ export interface Group {
|
|||
avatar?: string // 群头像
|
||||
notice?: string // 群公告
|
||||
ownerUserId?: number // 群主用户编号
|
||||
pinnedMessages?: Message[] // 群置顶消息列表
|
||||
|
||||
// ========== 前端扩展字段(user-per-group 维度) ==========
|
||||
muted?: boolean // 是否免打扰。从当前用户的 GroupMember 回填
|
||||
|
|
|
|||
|
|
@ -34,12 +34,18 @@ export const ImMessageType = {
|
|||
GROUP_ADMIN_REMOVE: 1518, // 撤销管理员
|
||||
GROUP_NOTICE_UPDATE: 1519, // 群公告变更
|
||||
GROUP_NAME_UPDATE: 1520, // 群名变更
|
||||
GROUP_MEMBER_SETTING_UPDATE: 1530 // 群成员个人设置变更:muted / groupRemark 个人多端同步
|
||||
GROUP_MEMBER_SETTING_UPDATE: 1530, // 群成员个人设置变更:muted / groupRemark 个人多端同步
|
||||
GROUP_MESSAGE_PIN: 1531, // 群消息置顶
|
||||
GROUP_MESSAGE_UNPIN: 1532 // 群消息取消置顶
|
||||
} as const
|
||||
|
||||
/** 判断是否「群广播事件」:[GROUP_CREATE, GROUP_MEMBER_SETTING_UPDATE) 段位都算,GROUP_MEMBER_SETTING_UPDATE 是个人信号不算 */
|
||||
/** 判断是否「群广播事件」:[GROUP_CREATE, GROUP_MESSAGE_UNPIN] 段位都算,仅 GROUP_MEMBER_SETTING_UPDATE 是个人信号排除 */
|
||||
export function isGroupNotification(type: number): boolean {
|
||||
return type >= ImMessageType.GROUP_CREATE && type < ImMessageType.GROUP_MEMBER_SETTING_UPDATE
|
||||
return (
|
||||
type >= ImMessageType.GROUP_CREATE
|
||||
&& type <= ImMessageType.GROUP_MESSAGE_UNPIN
|
||||
&& type !== ImMessageType.GROUP_MEMBER_SETTING_UPDATE
|
||||
)
|
||||
}
|
||||
|
||||
/** IM 普通消息类型集合(聊天气泡中显示,并作为会话最后一条摘要) */
|
||||
|
|
@ -98,6 +104,9 @@ export const ImGroupMemberRole = {
|
|||
/** 群管理员人数上限(对齐后端 GROUP_ADMIN_MAX_COUNT) */
|
||||
export const GROUP_ADMIN_MAX_COUNT = 3
|
||||
|
||||
/** 群置顶消息条数上限(对齐后端 GROUP_PIN_MAX_COUNT) */
|
||||
export const GROUP_PIN_MAX_COUNT = 5
|
||||
|
||||
/** 每次拉取私聊消息的最大条数(后端上限 1000,前端取保守值 100) */
|
||||
export const PRIVATE_MESSAGE_PULL_SIZE = 100
|
||||
|
||||
|
|
|
|||
|
|
@ -166,6 +166,22 @@ export type GroupNotificationPayload = {
|
|||
oldAvatar?: string
|
||||
newAvatar?: string
|
||||
displayUserName?: string
|
||||
messageId?: number
|
||||
/** PIN 事件携带的完整被置顶消息对象(前端直接 push 进 group.pinnedMessages,避免回查群详情) */
|
||||
message?: {
|
||||
id: number
|
||||
clientMessageId?: string
|
||||
senderId: number
|
||||
groupId: number
|
||||
type: number
|
||||
content: string
|
||||
status: number
|
||||
sendTime: string
|
||||
atUserIds?: number[]
|
||||
receiverUserIds?: number[]
|
||||
receiptStatus?: number
|
||||
readCount?: number
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveGroupNotificationText(
|
||||
|
|
@ -216,6 +232,10 @@ export function resolveGroupNotificationText(
|
|||
return `${operatorName} 撤销了 ${memberNames} 的管理员身份`
|
||||
case ImMessageType.GROUP_OWNER_TRANSFER:
|
||||
return `${operatorName} 已将群主转让给 ${newOwnerName}`
|
||||
case ImMessageType.GROUP_MESSAGE_PIN:
|
||||
return `${operatorName} 置顶了一条消息`
|
||||
case ImMessageType.GROUP_MESSAGE_UNPIN:
|
||||
return `${operatorName} 取消了一条置顶消息`
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue