✨ feat(im): 增加群消息的置顶
parent
01e0e8e37b
commit
7c129c18c4
|
|
@ -13,7 +13,6 @@ export interface ImGroupRespVO {
|
|||
status: number // 群状态(0=正常,1=已解散)
|
||||
dissolvedTime?: string // 解散时间
|
||||
createTime?: string // 创建时间
|
||||
// TODO @AI:不太对,返回的就是 ImGroupMessageRespVO 数组
|
||||
pinnedMessages?: ImGroupMessageRespVO[] // 群置顶消息列表(后端关联回填,仅当登录用户是群成员时非空)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import request from '@/config/axios'
|
||||
|
||||
// TODO @AI:应该是 message/group/xxx,保持和前端一致
|
||||
export interface ImManagerGroupMessageVO {
|
||||
id: number
|
||||
clientMessageId?: string
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import request from '@/config/axios'
|
||||
|
||||
// TODO @AI:应该是 message/group/xxx,保持和前端一致
|
||||
export interface ImManagerPrivateMessageVO {
|
||||
id: number
|
||||
clientMessageId?: string
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}
|
||||
return text.replace(/[&<>"']/g, (char) => map[char])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}
|
||||
return text.replace(/[&<>"']/g, (char) => map[char])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,12 +128,11 @@ watch(
|
|||
|
||||
/** 重建工作副本:把 checkedIds / lockedIds / hideIds 翻译成每个 member 的 flag */
|
||||
function rebuild() {
|
||||
// TODO @AI:member 不要缩写成 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 @AI:member?
|
||||
// TODO @AI:val 改成 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 @AI:member?
|
||||
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:直接落 val(locked 已由 disabled 拦截) */
|
||||
// TODO @AI:member?
|
||||
function handleCheckChange(m: GroupMemberFlag, val: boolean) {
|
||||
applyCheck(m, val)
|
||||
/** checkbox change:直接落 checked(locked 已由 disabled 拦截) */
|
||||
function handleCheckChange(member: GroupMemberFlag, checked: boolean) {
|
||||
applyCheck(member, checked)
|
||||
}
|
||||
|
||||
/** 确定:把已勾选成员通过 complete 抛给父侧 */
|
||||
|
|
|
|||
|
|
@ -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 @AI:MessageItem 也有一份完全相同的实现,下次顺手抽到 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) {
|
||||
|
|
|
|||
|
|
@ -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('已置顶')
|
||||
|
|
|
|||
|
|
@ -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 @AI:newMembers 更合适?
|
||||
// TODO @AI:m 是不是改成 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 @AI:TS18048: 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()
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in New Issue