feat(im): 增加群消息的置顶

im
YunaiV 2026-05-03 12:53:24 +08:00
parent 01e0e8e37b
commit 7c129c18c4
12 changed files with 66 additions and 108 deletions

View File

@ -13,7 +13,6 @@ export interface ImGroupRespVO {
status: number // 群状态0=正常1=已解散)
dissolvedTime?: string // 解散时间
createTime?: string // 创建时间
// TODO @AI不太对返回的就是 ImGroupMessageRespVO 数组
pinnedMessages?: ImGroupMessageRespVO[] // 群置顶消息列表(后端关联回填,仅当登录用户是群成员时非空)
}

View File

@ -1,6 +1,5 @@
import request from '@/config/axios'
// TODO @AI应该是 message/group/xxx保持和前端一致
export interface ImManagerGroupMessageVO {
id: number
clientMessageId?: string

View File

@ -1,6 +1,5 @@
import request from '@/config/axios'
// TODO @AI应该是 message/group/xxx保持和前端一致
export interface ImManagerPrivateMessageVO {
id: number
clientMessageId?: string

View File

@ -69,5 +69,5 @@ export const getGroupReadUsers = (params: {
groupId: number | string
messageId: number | string
}) => {
return request.get<number[]>({ url: '/im/message/group/read-users', params })
return request.get<number[]>({ url: '/im/message/group/get-read-user-ids', params })
}

View File

@ -38,7 +38,6 @@ export const pullPrivateMessages = (params: { minId: number | string; size: numb
}
// 查询私聊历史消息
// TODO @AI历史消息是不是通过这个接口
export const getPrivateMessageList = (params: ImPrivateMessageListReqVO) => {
return request.get<ImPrivateMessageRespVO[]>({ url: '/im/message/private/list', params })
}

View File

@ -288,15 +288,3 @@ export const isInContainer = (el: Element, container: any) => {
)
}
// TODO @AI拿到 /Users/yunai/Java/yudao-all-in-one/yudao-ui-admin-vue3/src/utils/index.ts放在 domutils 有点奇怪!
/** HTML 转义函数,防止 XSS */
export const escapeHtml = (text: string): string => {
const map: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
}
return text.replace(/[&<>"']/g, (char) => map[char])
}

View File

@ -535,3 +535,15 @@ export const subString = (str: string, start: number, end: number) => {
}
return str
}
/** HTML 转义函数,防止 XSS */
export const escapeHtml = (text: string): string => {
const map: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
}
return text.replace(/[&<>"']/g, (char) => map[char])
}

View File

@ -128,12 +128,11 @@ watch(
/** 重建工作副本:把 checkedIds / lockedIds / hideIds 翻译成每个 member 的 flag */
function rebuild() {
// TODO @AImember m
workingMembers.value = props.members.map((m) => ({
...m,
checked: props.checkedIds.some((id) => id === m.userId),
locked: props.lockedIds.some((id) => id === m.userId),
hide: props.hideIds.some((id) => id === m.userId)
workingMembers.value = props.members.map((member) => ({
...member,
checked: props.checkedIds.some((id) => id === member.userId),
locked: props.lockedIds.some((id) => id === member.userId),
hide: props.hideIds.some((id) => id === member.userId)
}))
}
@ -148,32 +147,28 @@ const showMembers = computed(() =>
)
/** 已勾选成员:右侧宫格预览 + complete 抛参 */
const checkedMembers = computed(() => workingMembers.value.filter((m) => m.checked))
const checkedMembers = computed(() => workingMembers.value.filter((member) => member.checked))
/** 落勾选并校验上限:超过 maxSize 时自动取消并提示,避免出现"勾上但实际不算"的中间态 */
// TODO @AImember
// TODO @AIval checked
function applyCheck(m: GroupMemberFlag, val: boolean) {
m.checked = val
function applyCheck(member: GroupMemberFlag, checked: boolean) {
member.checked = checked
if (props.maxSize > 0 && checkedMembers.value.length > props.maxSize) {
message.error(`最多选择 ${props.maxSize} 位成员`)
m.checked = false
member.checked = false
}
}
/** 行点击切换勾选态locked 的不响应 */
// TODO @AImember
function handleToggleCheck(m: GroupMemberFlag) {
if (m.locked) {
function handleToggleCheck(member: GroupMemberFlag) {
if (member.locked) {
return
}
applyCheck(m, !m.checked)
applyCheck(member, !member.checked)
}
/** checkbox change直接落 vallocked 已由 disabled 拦截) */
// TODO @AImember
function handleCheckChange(m: GroupMemberFlag, val: boolean) {
applyCheck(m, val)
/** checkbox change直接落 checkedlocked 已由 disabled 拦截) */
function handleCheckChange(member: GroupMemberFlag, checked: boolean) {
applyCheck(member, checked)
}
/** 确定:把已勾选成员通过 complete 抛给父侧 */

View File

@ -219,8 +219,8 @@
@click="openFile(fileOf(message)?.url)"
>
<Icon
:icon="getFileIcon(fileOf(message)?.name || '').icon"
:color="getFileIcon(fileOf(message)?.name || '').color"
:icon="getFileIconInfo(fileOf(message)?.name).icon"
:color="getFileIconInfo(fileOf(message)?.name).color"
:size="32"
class="flex-shrink-0"
/>
@ -320,6 +320,7 @@ import { ImConversationType, ImMessageType, isGroupNotification } from '@/views/
import {
parseMessage,
resolveTipText,
getFileIconInfo,
type TextMessage,
type ImageMessage,
type FileMessage,
@ -686,42 +687,6 @@ function textSnippetOf(message: Message): string {
}
}
/**
* 文件类型图标 + 配色按扩展名分发 MessageItem 同款逻辑
*
* TODO @AIMessageItem 也有一份完全相同的实现下次顺手抽到 utils/message.ts 里去重
*/
function getFileIcon(name: string): { icon: string; color: string } {
const ext = name.split('.').pop()?.toLowerCase() || ''
if (ext === 'pdf') {
return { icon: 'ant-design:file-pdf-filled', color: '#ed5757' }
}
if (['doc', 'docx'].includes(ext)) {
return { icon: 'ant-design:file-word-filled', color: '#2b7cd3' }
}
if (['xls', 'xlsx'].includes(ext)) {
return { icon: 'ant-design:file-excel-filled', color: '#1f7244' }
}
if (['ppt', 'pptx'].includes(ext)) {
return { icon: 'ant-design:file-ppt-filled', color: '#d24726' }
}
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) {
return { icon: 'ant-design:file-zip-filled', color: '#f0ad4e' }
}
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(ext)) {
return { icon: 'ant-design:file-image-filled', color: '#9c27b0' }
}
if (['mp4', 'mov', 'avi', 'mkv', 'wmv', 'flv'].includes(ext)) {
return { icon: 'ant-design:video-camera-filled', color: '#9c27b0' }
}
if (['mp3', 'wav', 'ogg', 'flac', 'aac'].includes(ext)) {
return { icon: 'ant-design:audio-filled', color: '#9c27b0' }
}
if (['txt', 'md', 'log', 'json', 'xml'].includes(ext)) {
return { icon: 'ant-design:file-text-filled', color: '#909399' }
}
return { icon: 'ant-design:file-filled', color: '#909399' }
}
/** 文件点击 → 新窗口打开下载 */
function openFile(url?: string) {

View File

@ -267,8 +267,6 @@ 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<{
@ -280,6 +278,8 @@ const emit = defineEmits<{
locate: [messageId: number]
}>()
// ==================== Stores / Hooks ====================
const userStore = useUserStore()
const conversationStore = useConversationStore()
const groupStore = useGroupStore()
@ -290,6 +290,8 @@ const { recall, sendRaw } = useMessageSender()
// confirm message props.message vue/no-dupe-keys
const { confirm: confirmDialog, success: successMessage } = useMessage()
// ==================== ====================
/** 是否已撤回pull / WS 两路都会调 recallMessage 把原消息更新为 type=RECALL渲染只需识别 type */
const isRecall = computed(() => props.message.type === ImMessageType.RECALL)
@ -362,6 +364,8 @@ function formatTipTime(timestamp: number): string {
return `${pad(messageDate.getMonth() + 1)}-${pad(messageDate.getDate())} ${hourMinute}`
}
// ==================== / payload ====================
/** 文本内容 */
const textContent = computed(() => parseMessage<TextMessage>(props.message.content)?.content ?? '')
@ -427,6 +431,8 @@ onBeforeUnmount(() => {
voicePlaying.value = false
})
// ==================== / / @ ====================
// buildRecallTip sender conversation WeChat
const recallTip = computed(() => {
const conversation = conversationStore.activeConversation
@ -518,6 +524,8 @@ const isAtMe = computed(() => {
return (props.message.atUserIds || []).includes(myId)
})
// ==================== / ====================
/** 右键菜单 key 常量push 端和分发端从同一处取typo 编译期就能抓 */
const MENU_KEYS = {
REPLY: 'REPLY',
@ -630,7 +638,8 @@ const canPin = computed(
isNormalMessage(props.message.type) &&
!!props.message.id &&
!isRecall.value &&
(myGroupRole.value === ImGroupMemberRole.OWNER || myGroupRole.value === ImGroupMemberRole.ADMIN) &&
(myGroupRole.value === ImGroupMemberRole.OWNER ||
myGroupRole.value === ImGroupMemberRole.ADMIN) &&
!currentGroup.value.pinnedMessages?.some((m) => m.id === props.message.id)
)
@ -641,6 +650,7 @@ async function handlePin() {
return
}
try {
// TODO @AI TS2554: Expected 1-2 arguments, but got 3
await confirmDialog('将在当前群成员的聊天中置顶', '置顶消息', { confirmButtonText: '置顶' })
await apiPinGroupMessage({ groupId: group.id, messageId: props.message.id })
successMessage('已置顶')

View File

@ -406,21 +406,19 @@ export const useGroupStore = defineStore('imGroupStore', {
if (!group?.members?.length) {
return
}
// TODO @AI计算是否更新【优化注释】
// 命中目标且角色已变化才标记 changed避免无变化时整数组重建触发响应式
const idSet = new Set(userIds)
let changed = false
// TODO @AInewMembers 更合适?
// TODO @AIm 是不是改成 member
const next = group.members.map((m) => {
if (!idSet.has(m.userId) || m.role === role) {
return m
const newMembers = group.members.map((member) => {
if (!idSet.has(member.userId) || member.role === role) {
return member
}
changed = true
return { ...m, role }
return { ...member, role }
})
// TODO @AI有更新则进行替换补充下注释【优化注释】
// 有变化才整组替换,让响应式只在真有更新时通知下游
if (changed) {
group.members = next
group.members = newMembers
}
},
@ -644,28 +642,23 @@ export const useGroupStore = defineStore('imGroupStore', {
if (!group) {
return
}
// TODO @AI可以直接使用 payload简化这样设置。。。
const message: Message = {
id: payload.message.id,
// 幂等:已存在同 messageId 不重复 push
const existing = group.pinnedMessages || []
// TODO @AITS18048: payload.message is possibly undefined
if (existing.some((m) => m.id === payload.message.id)) {
return
}
// payload.message 字段名跟 Message 一致(仅 sendTime 需要从 ISO 串转毫秒戳selfSend 按当前用户算)
const newMessage: Message = {
...payload.message,
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
receiverUserIds: payload.message.receiverUserIds || []
}
// 幂等:已存在同 messageId 不重复 push
const existing = group.pinnedMessages || []
if (existing.some((m) => m.id === message.id)) {
return
}
group.pinnedMessages = [...existing, message]
group.pinnedMessages = [...existing, newMessage]
this.saveGroups()
},
@ -678,12 +671,11 @@ export const useGroupStore = defineStore('imGroupStore', {
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) {
const newPinnedMessages = group.pinnedMessages.filter((m) => m.id !== payload.messageId)
if (newPinnedMessages.length === group.pinnedMessages.length) {
return
}
group.pinnedMessages = next
group.pinnedMessages = newPinnedMessages
this.saveGroups()
},

View File

@ -79,7 +79,7 @@ import { ref } from 'vue'
import { useMessage } from '@/hooks/web/useMessage'
import { DICT_TYPE } from '@/utils/dict'
import { formatDate } from '@/utils/formatTime'
import { escapeHtml } from '@/utils/domUtils'
import { escapeHtml } from '@/utils'
import download from '@/utils/download'
import Barcode from './Barcode.vue'
import { WmBarcodeApi, type WmBarcodeVO } from '@/api/mes/wm/barcode'