diff --git a/src/api/im/group/index.ts b/src/api/im/group/index.ts index ff732fa19..cd931cd9d 100644 --- a/src/api/im/group/index.ts +++ b/src/api/im/group/index.ts @@ -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({ url: '/im/group/transfer-owner', data }) } + +// 置顶群消息(仅群主 / 管理员可调) +export const pinGroupMessage = (data: ImGroupMessagePinReqVO) => { + return request.put({ url: '/im/group/pin-message', data }) +} + +// 取消置顶群消息(仅群主 / 管理员可调) +export const unpinGroupMessage = (data: ImGroupMessagePinReqVO) => { + return request.put({ url: '/im/group/unpin-message', data }) +} diff --git a/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue b/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue index 5a0d62f7c..4ecda7bc0 100644 --- a/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue +++ b/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue @@ -237,6 +237,9 @@ + +
+
myRole.value === ImGroupMemberRole.OWNER || myRole.value === ImGroupMemberRole.ADMIN ) + // 排除已退群成员 + 关键字过滤;按角色排序:群主→管理员→普通成员(同角色按 userId 稳定) const visibleMembers = computed(() => { return props.members diff --git a/src/views/im/home/pages/conversation/components/message/ConversationGroupPinned.vue b/src/views/im/home/pages/conversation/components/message/ConversationGroupPinned.vue new file mode 100644 index 000000000..6a27b97a0 --- /dev/null +++ b/src/views/im/home/pages/conversation/components/message/ConversationGroupPinned.vue @@ -0,0 +1,251 @@ + + + + + diff --git a/src/views/im/home/pages/conversation/components/message/MessageItem.vue b/src/views/im/home/pages/conversation/components/message/MessageItem.vue index a111ed17e..11f1a5b5c 100644 --- a/src/views/im/home/pages/conversation/components/message/MessageItem.vue +++ b/src/views/im/home/pages/conversation/components/message/MessageItem.vue @@ -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 diff --git a/src/views/im/home/pages/conversation/components/message/MessagePanel.vue b/src/views/im/home/pages/conversation/components/message/MessagePanel.vue index 0931ec081..58ed48784 100644 --- a/src/views/im/home/pages/conversation/components/message/MessagePanel.vue +++ b/src/views/im/home/pages/conversation/components/message/MessagePanel.vue @@ -1,61 +1,68 @@