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

View File

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

View File

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

View File

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

View File

@ -219,8 +219,8 @@
@click="openFile(fileOf(message)?.url)" @click="openFile(fileOf(message)?.url)"
> >
<Icon <Icon
:icon="getFileIcon(fileOf(message)?.name || '').icon" :icon="getFileIconInfo(fileOf(message)?.name).icon"
:color="getFileIcon(fileOf(message)?.name || '').color" :color="getFileIconInfo(fileOf(message)?.name).color"
:size="32" :size="32"
class="flex-shrink-0" class="flex-shrink-0"
/> />
@ -320,6 +320,7 @@ import { ImConversationType, ImMessageType, isGroupNotification } from '@/views/
import { import {
parseMessage, parseMessage,
resolveTipText, resolveTipText,
getFileIconInfo,
type TextMessage, type TextMessage,
type ImageMessage, type ImageMessage,
type FileMessage, 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) { function openFile(url?: string) {

View File

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

View File

@ -406,21 +406,19 @@ export const useGroupStore = defineStore('imGroupStore', {
if (!group?.members?.length) { if (!group?.members?.length) {
return return
} }
// TODO @AI计算是否更新【优化注释】 // 命中目标且角色已变化才标记 changed避免无变化时整数组重建触发响应式
const idSet = new Set(userIds) const idSet = new Set(userIds)
let changed = false let changed = false
// TODO @AInewMembers 更合适? const newMembers = group.members.map((member) => {
// TODO @AIm 是不是改成 member if (!idSet.has(member.userId) || member.role === role) {
const next = group.members.map((m) => { return member
if (!idSet.has(m.userId) || m.role === role) {
return m
} }
changed = true changed = true
return { ...m, role } return { ...member, role }
}) })
// TODO @AI有更新则进行替换补充下注释【优化注释】 // 有变化才整组替换,让响应式只在真有更新时通知下游
if (changed) { if (changed) {
group.members = next group.members = newMembers
} }
}, },
@ -644,28 +642,23 @@ export const useGroupStore = defineStore('imGroupStore', {
if (!group) { if (!group) {
return return
} }
// TODO @AI可以直接使用 payload简化这样设置。。。 // 幂等:已存在同 messageId 不重复 push
const message: Message = { const existing = group.pinnedMessages || []
id: payload.message.id, // 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 || '', clientMessageId: payload.message.clientMessageId || '',
type: payload.message.type,
content: payload.message.content,
status: payload.message.status,
sendTime: new Date(payload.message.sendTime).getTime(), sendTime: new Date(payload.message.sendTime).getTime(),
senderId: payload.message.senderId,
targetId: payload.message.groupId, targetId: payload.message.groupId,
selfSend: payload.message.senderId === getCurrentUserId(), selfSend: payload.message.senderId === getCurrentUserId(),
atUserIds: payload.message.atUserIds || [], atUserIds: payload.message.atUserIds || [],
receiverUserIds: payload.message.receiverUserIds || [], receiverUserIds: payload.message.receiverUserIds || []
receiptStatus: payload.message.receiptStatus,
readCount: payload.message.readCount
} }
// 幂等:已存在同 messageId 不重复 push group.pinnedMessages = [...existing, newMessage]
const existing = group.pinnedMessages || []
if (existing.some((m) => m.id === message.id)) {
return
}
group.pinnedMessages = [...existing, message]
this.saveGroups() this.saveGroups()
}, },
@ -678,12 +671,11 @@ export const useGroupStore = defineStore('imGroupStore', {
if (!group?.pinnedMessages?.length) { if (!group?.pinnedMessages?.length) {
return return
} }
// TODO @AI不要用 next 这样的单词,大家不好理解。可以用 newXXXX 这样。其它地方也看看。 const newPinnedMessages = group.pinnedMessages.filter((m) => m.id !== payload.messageId)
const next = group.pinnedMessages.filter((m) => m.id !== payload.messageId) if (newPinnedMessages.length === group.pinnedMessages.length) {
if (next.length === group.pinnedMessages.length) {
return return
} }
group.pinnedMessages = next group.pinnedMessages = newPinnedMessages
this.saveGroups() this.saveGroups()
}, },

View File

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