多选模式1

This commit is contained in:
bob
2025-05-08 09:17:27 +08:00
parent 94bfe0e957
commit 75d37d0ceb
5 changed files with 471 additions and 112 deletions

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1746605979357" class="svg-icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5945" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M850.538343 895.516744c-11.494799 0-22.988574-4.386914-31.763424-13.161764L141.103692 204.669426c-17.548678-17.534352-17.548678-45.992497 0-63.525825 17.548678-17.548678 45.977147-17.548678 63.525825 0l677.671227 677.685553c17.548678 17.534352 17.548678 45.992497 0 63.525825C873.526917 891.128807 862.032118 895.516744 850.538343 895.516744z" fill="#000000" p-id="5946"></path><path d="M172.867116 895.516744c-11.494799 0-22.988574-4.386914-31.763424-13.161764-17.548678-17.534352-17.548678-45.992497 0-63.525825l677.671227-677.685553c17.548678-17.548678 45.977147-17.548678 63.525825 0 17.548678 17.534352 17.548678 45.992497 0 63.525825L204.629517 882.354979C195.85569 891.128807 184.360891 895.516744 172.867116 895.516744z" fill="#000000" p-id="5947"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1746604353367" class="svg-icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4613" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M933.686613 826.823111c0 33.28-26.908444 60.245333-60.131555 60.245333H151.350613a60.188444 60.188444 0 0 1-60.188444-60.245333H0.879502c-9.102222 93.809778 53.930667 150.641778 120.376889 150.641778h782.392889A120.433778 120.433778 0 0 0 1024.026169 856.974222v-180.679111h-90.339556v150.528zM978.91328 0H587.688391a45.169778 45.169778 0 0 0 0 90.282667h297.244445L446.490169 529.123556a45.169778 45.169778 0 0 0 63.829333 63.886222l423.367111-423.765334v267.434667a45.169778 45.169778 0 0 0 90.225778 0V45.169778a44.942222 44.942222 0 0 0-44.942222-45.169778z" fill="#000000" p-id="4614"></path><path d="M0.026169 102.4m42.666667 0l426.666666 0q42.666667 0 42.666667 42.666667l0 0q0 42.666667-42.666667 42.666666l-426.666666 0q-42.666667 0-42.666667-42.666666l0 0q0-42.666667 42.666667-42.666667Z" fill="#000000" p-id="4615"></path><path d="M0.026169 327.395556m42.666667 0l290.133333 0q42.666667 0 42.666667 42.666666l0 0q0 42.666667-42.666667 42.666667l-290.133333 0q-42.666667 0-42.666667-42.666667l0 0q0-42.666667 42.666667-42.666666Z" fill="#000000" p-id="4616"></path><path d="M0.026169 552.334222m42.666667 0l290.133333 0q42.666667 0 42.666667 42.666667l0 0q0 42.666667-42.666667 42.666667l-290.133333 0q-42.666667 0-42.666667-42.666667l0 0q0-42.666667 42.666667-42.666667Z" fill="#000000" p-id="4617"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -52,6 +52,7 @@ import MenuMsgMain from '@/views/message/components/MenuMsgMain.vue'
import MessageGroupRightSide from '@/views/message/components/MessageGroupRightSide.vue'
import HashNoData from '@/components/common/HasNoData.vue'
import InputRecorder from '@/views/message/components/InputRecorder.vue'
import InputMultiSelect from '@/views/message/components/InputMultiSelect.vue'
import { playMsgSend } from '@/js/utils/audio'
const userData = useUserStore()
@@ -255,6 +256,7 @@ const initSession = (sessionId) => {
}
isShowRecorder.value = false // 麦克风输入状态重置
inputRecorderRef.value?.cancelSend() // 取消音频发送
inputMultiSelectRef.value?.cancel() // 取消多选模式
imageData.clearImageInSession(sessionId) // 清除待渲染的图片队列
readAtMsgIds.value = []
}
@@ -1153,6 +1155,153 @@ const onSelectOprMenu = (label) => {
}
}
const inputMultiSelectRef = ref(null)
const isMultiSelect = ref(false)
const multiSelectedMsgIds = ref(new Set())
const handleMsgItemSelect = (msgKey, selected) => {
if (!isMultiSelect.value) {
isMultiSelect.value = true
}
if (selected) {
multiSelectedMsgIds.value.add(msgKey)
} else {
multiSelectedMsgIds.value.delete(msgKey)
}
}
const handleCancleMultiSelect = () => {
isMultiSelect.value = false
multiSelectedMsgIds.value.clear()
}
// const toggleMultiSelect = () => {
// isMultiSelect.value = !isMultiSelect.value
// if (!isMultiSelect.value) {
// multiSelectedMsgIds.value.clear()
// }
// }
// const handleDeleteMessages = async () => {
// // 实现批量删除逻辑
// }
// const handleForwardSelected = () => {
// // 实现批量转发逻辑
// }
// 选区相关状态
const selection = ref({
isSelecting: false,
startX: 0,
startY: 0,
currentX: 0,
currentY: 0
})
// 计算选区样式
const selectionStyle = computed(() => {
if (!selection.value.isSelecting) return { display: 'none' }
const left = Math.min(selection.value.startX, selection.value.currentX)
const top = Math.min(selection.value.startY, selection.value.currentY)
const width = Math.abs(selection.value.currentX - selection.value.startX)
const height = Math.abs(selection.value.currentY - selection.value.startY)
return {
display: 'block',
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`
}
})
// 处理鼠标按下
const handleMouseDown = (e) => {
if (e.button !== 0) return // 如果不是左键则返回
const rect = msgListDiv.value.getBoundingClientRect()
selection.value.isSelecting = true
selection.value.startX = e.clientX - rect.left
selection.value.startY = e.clientY - rect.top
selection.value.currentX = selection.value.startX
selection.value.currentY = selection.value.startY
// 添加全局监听
document.addEventListener('mousemove', handleGlobalMouseMove)
document.addEventListener('mouseup', handleGlobalMouseUp)
}
// 处理鼠标移动(节流处理)
const handleGlobalMouseMove = (e) => {
if (!selection.value.isSelecting) return
const rect = msgListDiv.value.getBoundingClientRect()
selection.value.currentX = e.clientX - rect.left
selection.value.currentY = e.clientY - rect.top
}
// 处理鼠标释放
const handleGlobalMouseUp = (e) => {
// 只在鼠标左键释放时处理
if (e.button !== 0) return
document.removeEventListener('mousemove', handleGlobalMouseMove)
document.removeEventListener('mouseup', handleGlobalMouseUp)
if (
!selection.value.isSelecting ||
(selection.value.isSelecting &&
selection.value.currentX === selection.value.startX &&
selection.value.currentY === selection.value.startY)
) {
selection.value.isSelecting = false
return
}
selection.value.isSelecting = false
// 检测选区内的消息项
const selectionRect = {
left: Math.min(selection.value.startX, selection.value.currentX),
top: Math.min(selection.value.startY, selection.value.currentY),
right: Math.max(selection.value.startX, selection.value.currentX),
bottom: Math.max(selection.value.startY, selection.value.currentY)
}
const rect = msgListDiv.value.getBoundingClientRect()
msgListDiv.value.querySelectorAll('.message-item').forEach((el) => {
const itemRect = el.getBoundingClientRect()
const itemLeft = itemRect.left - rect.left
const itemTop = itemRect.top - rect.top
const itemRight = itemRect.right - rect.left
const itemBottom = itemRect.bottom - rect.top
const isIntersect = !(
itemBottom < selectionRect.top ||
itemTop > selectionRect.bottom ||
itemRight < selectionRect.left ||
itemLeft > selectionRect.right
)
if (isIntersect) {
if (!isMultiSelect.value) {
isMultiSelect.value = true
}
const msgId = el.dataset.msgId
const isRecording = el.dataset.isRecording
if (multiSelectedMsgIds.value.has(msgId)) {
multiSelectedMsgIds.value.delete(msgId)
} else if (isRecording !== 'true') {
// 语音消息不能被选中
multiSelectedMsgIds.value.add(msgId)
}
}
})
}
const isShowForwardMsgDialog = ref(false)
let forwardMsg = {} // 待转发的消息
const sessionListSortedKey = computed(() => {
@@ -1429,7 +1578,9 @@ const onShowRecorder = () => {
class="message-main my-scrollbar"
ref="msgListDiv"
@wheel="handleMsgListWheel"
@mousedown="handleMouseDown"
>
<div class="selection-box" :style="selectionStyle"></div>
<MenuMsgMain @selectMenu="onSelectMsgMainMenu">
<MessageItem
v-for="item in msgKeysShow"
@@ -1447,6 +1598,8 @@ const onShowRecorder = () => {
:hasNoMoreMsg="hasNoMoreMsg"
:isLoadMoreLoading="selectedSessionCache[selectedSessionId]?.isLoadMoreLoading"
:inputEditorRef="inputEditorRef"
:isMultiSelect="isMultiSelect"
:isSelected="multiSelectedMsgIds.has(item)"
@loadMore="onLoadMore"
@showUserCard="onShowUserCard"
@showGroupCard="onShowGroupCard"
@@ -1454,6 +1607,7 @@ const onShowRecorder = () => {
@loadFinished="updateScroll"
@showHighlight="handleShowHighlight"
@forwardMsg="showForwardMsgDialog"
@select="handleMsgItemSelect"
></MessageItem>
</MenuMsgMain>
</div>
@@ -1495,7 +1649,14 @@ const onShowRecorder = () => {
</transition>
</div>
<div class="input-box bdr-t" :style="{ height: inputBoxHeight + 'px' }">
<el-container v-if="isShowRecorder">
<el-container v-if="isMultiSelect">
<InputMultiSelect
ref="inputMultiSelectRef"
:selectedCount="multiSelectedMsgIds.size"
@exit="handleCancleMultiSelect"
></InputMultiSelect>
</el-container>
<el-container v-else-if="isShowRecorder">
<InputRecorder
ref="inputRecorderRef"
@exit="isShowRecorder = false"
@@ -1749,6 +1910,13 @@ const onShowRecorder = () => {
padding: 10px;
overflow-y: scroll; // 用它的滚动条
.selection-box {
position: absolute;
background-color: rgba(0, 0, 0, 0.1);
pointer-events: none;
z-index: 1000;
}
.message-item {
transition: all 1s ease;
}

View File

@@ -0,0 +1,109 @@
<script setup>
import { onMounted, onUnmounted } from 'vue'
import ForwardIcon from '@/assets/svg/forward.svg'
import ForwardoboIcon from '@/assets/svg/forwardobo.svg'
import DeletemsgIcon from '@/assets/svg/deletemsg.svg'
import CancleIcon from '@/assets/svg/cancle.svg'
const props = defineProps(['selectedCount'])
const emit = defineEmits(['exit', 'forwardTogether', 'forwardOneByOne', 'batchDelete'])
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
cancel()
}
}
const cancel = () => {
emit('exit')
}
defineExpose({ cancel })
onMounted(() => {
window.addEventListener('keydown', handleKeyDown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown)
})
</script>
<template>
<div class="input-multi-select">
<span class="selected-count">已选中{{ props.selectedCount || 0 }}条消息</span>
<div class="multi-select-funtions">
<div class="function-item">
<div class="fun-icon">
<ForwardIcon></ForwardIcon>
</div>
<span>合并转发</span>
</div>
<div class="function-item">
<div class="fun-icon">
<ForwardoboIcon style="width: 20px; height: 20px"></ForwardoboIcon>
</div>
<span>逐条转发</span>
</div>
<div class="function-item">
<div class="fun-icon">
<DeletemsgIcon></DeletemsgIcon>
</div>
<span>批量删除</span>
</div>
<div class="function-item">
<div class="fun-icon" @click="cancel">
<CancleIcon></CancleIcon>
</div>
<span>取消</span>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.input-multi-select {
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 20px;
font-size: 14px;
color: gray;
.multi-select-funtions {
display: flex;
gap: 24px;
.function-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
.fun-icon {
width: 64px;
height: 64px;
border-radius: 50%;
background-color: #fff;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
transition: background-color 0.3s;
&:hover {
background-color: #409eff;
}
}
}
}
}
.svg-icon {
width: 24px;
height: 24px;
// fill: gray;
}
</style>

View File

@@ -36,7 +36,9 @@ const props = defineProps([
'lastMsgId',
'hasNoMoreMsg',
'isLoadMoreLoading',
'inputEditorRef'
'inputEditorRef',
'isMultiSelect',
'isSelected'
])
const emit = defineEmits([
'loadMore',
@@ -45,7 +47,8 @@ const emit = defineEmits([
'resendMsg',
'loadFinished',
'showHighlight',
'forwardMsg'
'forwardMsg',
'select'
])
const userData = useUserStore()
@@ -345,6 +348,21 @@ const renderDocument = (content) => {
}
}
const contentType = computed(() => {
const contentJson = jsonParseSafe(msg.value.content)
if (!contentJson) {
return msgContentType.MIX
}
const type = contentJson['type']
const value = contentJson['value']
if (!type || !value) {
return msgContentType.MIX
} else {
return type
}
})
const msg = computed(() => {
return messageData.getMsg(props.sessionId, props.msgKey)
})
@@ -854,6 +872,9 @@ const onSelectMenuMsgItem = async (label) => {
case 'forward':
emit('forwardMsg', props.msgKey)
break
case 'multiSelect':
emit('select', props.msgKey, true)
break
default:
break
}
@@ -876,132 +897,191 @@ watch(
}
}
)
const handleItemClick = () => {
if (props.isMultiSelect && contentType.value !== msgContentType.RECORDING) {
emit('select', props.msgKey, !props.isSelected)
}
}
</script>
<template>
<div class="message-item" :class="{ unreadMsg: isUnreadMsg }">
<span v-if="isShowNoMoreMsg" class="no-more-message">当前无更多消息</span>
<div v-if="isShowLoadMore" class="load-more-wrapper">
<div
class="load-more"
v-loading="props.isLoadMoreLoading"
@click="onLoadMore"
:style="{ cursor: loadMoreCursor }"
>
{{ loadMoreTips }}
</div>
<div
class="message-item-wrapper"
:class="{
'multi-select-mode': props.isMultiSelect,
'is-selected': props.isSelected,
'is-valid-option': props.isMultiSelect && contentType !== msgContentType.RECORDING
}"
@click="handleItemClick"
>
<div v-if="props.isMultiSelect" class="message-checkbox">
<el-checkbox
:model-value="props.isSelected"
:disabled="contentType === msgContentType.RECORDING"
@update:model-value="handleItemClick"
@click.stop
/>
</div>
<el-divider v-if="props.extend.isFirstNew" class="new-messages-tips" content-position="center"
>以下是新消息</el-divider
>
<span v-if="!isContinuousSession" class="datetime">{{ sysShowTime }}</span>
<div
v-if="isSystemMsg"
class="system-message"
v-html="systemMsgContent"
@click="onClickSystemMsg"
></div>
<div v-else-if="!isSystemMsg && isRevoke" class="revoke-delete">
<div v-if="isSelf">
<span>你撤回了一条消息</span>
<span
v-if="isReedit && !isReeditTimeOut"
style="margin-left: 2px; color: #409eff; cursor: pointer"
@click="handleReedit"
class="message-item"
:data-msg-id="props.msgKey"
:data-is-recording="contentType === msgContentType.RECORDING"
:class="{ unreadMsg: isUnreadMsg }"
>
<span v-if="isShowNoMoreMsg" class="no-more-message">当前无更多消息</span>
<div v-if="isShowLoadMore" class="load-more-wrapper">
<div
class="load-more"
v-loading="props.isLoadMoreLoading"
@click="onLoadMore"
:style="{ cursor: loadMoreCursor }"
>
重新编辑
</span>
{{ loadMoreTips }}
</div>
</div>
<div v-else>
<div v-if="isChatMsgType">对方撤回了一条消息</div>
<div v-else>{{ `${objectInfoFromMsg.nickName}撤回了一条消息` }}</div>
<el-divider v-if="props.extend.isFirstNew" class="new-messages-tips" content-position="center"
>以下是新消息</el-divider
>
<span v-if="!isContinuousSession" class="datetime">{{ sysShowTime }}</span>
<div
v-if="isSystemMsg"
class="system-message"
v-html="systemMsgContent"
@click="onClickSystemMsg"
></div>
<div v-else-if="!isSystemMsg && isRevoke" class="revoke-delete">
<div v-if="isSelf">
<span>你撤回了一条消息</span>
<span
v-if="isReedit && !isReeditTimeOut"
style="margin-left: 2px; color: #409eff; cursor: pointer"
@click="handleReedit"
>
重新编辑
</span>
</div>
<div v-else>
<div v-if="isChatMsgType">对方撤回了一条消息</div>
<div v-else>{{ `${objectInfoFromMsg.nickName}撤回了一条消息` }}</div>
</div>
</div>
</div>
<div v-else-if="!isSystemMsg && isDelete" class="revoke-delete">
<span>消息已删除</span>
</div>
<div v-else class="message-container-wrapper">
<el-container class="el-container-right" v-if="isSelf">
<el-main class="el-main-right">
<el-container class="message-content-wrapper">
<el-header class="message-time">
<span v-if="isGroupChatMsgType">{{ nickName }}</span>
<span style="margin-left: 5px">{{ msgTime }}</span>
</el-header>
<el-main class="message-content">
<div
v-if="msgStatus === msgSendStatus.PENDING"
class="my-message-status my-message-status-pending"
>
<div class="loading-circular" v-loading="true"></div>
</div>
<div
v-else-if="
msgStatus === msgSendStatus.FAILED || msgStatus === msgSendStatus.UPLOAD_FAILED
"
class="my-message-status my-message-status-failed"
>
<el-icon
color="red"
:title="msgStatus === msgSendStatus.FAILED ? '点击重发' : ''"
@click="onResendMsg"
><WarningFilled
/></el-icon>
</div>
<div v-else-if="isChatMsgType" class="my-message-status">
<div v-if="myMsgIsRead" class="remote_read"></div>
<div v-else class="remote_unread"></div>
</div>
<MenuMsgItem :msg="msg" @selectMenu="onSelectMenuMsgItem">
<div class="div-content" :id="`div-content-${msg.msgId}`"></div>
</MenuMsgItem>
</el-main>
</el-container>
</el-main>
<el-aside class="el-aside-right">
<UserAvatarIcon
class="avatar-message-item"
:showId="account"
:showName="nickName"
:showAvatarThumb="avatarThumb"
@click="onShowUserCard"
></UserAvatarIcon>
</el-aside>
</el-container>
<div v-else-if="!isSystemMsg && isDelete" class="revoke-delete">
<span>消息已删除</span>
</div>
<div v-else class="message-container-wrapper">
<el-container class="el-container-right" v-if="isSelf">
<el-main class="el-main-right">
<el-container class="message-content-wrapper">
<el-header class="message-time">
<span v-if="isGroupChatMsgType">{{ nickName }}</span>
<span style="margin-left: 5px">{{ msgTime }}</span>
</el-header>
<el-main class="message-content">
<div
v-if="msgStatus === msgSendStatus.PENDING"
class="my-message-status my-message-status-pending"
>
<div class="loading-circular" v-loading="true"></div>
</div>
<div
v-else-if="
msgStatus === msgSendStatus.FAILED || msgStatus === msgSendStatus.UPLOAD_FAILED
"
class="my-message-status my-message-status-failed"
>
<el-icon
color="red"
:title="msgStatus === msgSendStatus.FAILED ? '点击重发' : ''"
@click="onResendMsg"
><WarningFilled
/></el-icon>
</div>
<div v-else-if="isChatMsgType" class="my-message-status">
<div v-if="myMsgIsRead" class="remote_read"></div>
<div v-else class="remote_unread"></div>
</div>
<MenuMsgItem :msg="msg" @selectMenu="onSelectMenuMsgItem">
<div class="div-content" :id="`div-content-${msg.msgId}`"></div>
</MenuMsgItem>
</el-main>
</el-container>
</el-main>
<el-aside class="el-aside-right">
<UserAvatarIcon
class="avatar-message-item"
:showId="account"
:showName="nickName"
:showAvatarThumb="avatarThumb"
@click="onShowUserCard"
></UserAvatarIcon>
</el-aside>
</el-container>
<el-container class="el-container-left" v-else>
<el-aside class="el-aside-left">
<UserAvatarIcon
class="avatar-message-item"
:showId="account"
:showName="nickName"
:showAvatarThumb="avatarThumb"
@click="onShowUserCard"
></UserAvatarIcon>
</el-aside>
<el-main class="el-main-left">
<el-container class="message-content-wrapper">
<el-header class="message-time">
<span v-if="isGroupChatMsgType" style="margin-right: 5px">{{ nickName }}</span>
<span>{{ msgTime }}</span>
</el-header>
<el-main class="message-content">
<MenuMsgItem :msg="msg" @selectMenu="onSelectMenuMsgItem">
<div class="div-content" :id="`div-content-${msg.msgId}`"></div>
</MenuMsgItem>
</el-main>
</el-container>
</el-main>
</el-container>
<el-container class="el-container-left" v-else>
<el-aside class="el-aside-left">
<UserAvatarIcon
class="avatar-message-item"
:showId="account"
:showName="nickName"
:showAvatarThumb="avatarThumb"
@click="onShowUserCard"
></UserAvatarIcon>
</el-aside>
<el-main class="el-main-left">
<el-container class="message-content-wrapper">
<el-header class="message-time">
<span v-if="isGroupChatMsgType" style="margin-right: 5px">{{ nickName }}</span>
<span>{{ msgTime }}</span>
</el-header>
<el-main class="message-content">
<MenuMsgItem :msg="msg" @selectMenu="onSelectMenuMsgItem">
<div class="div-content" :id="`div-content-${msg.msgId}`"></div>
</MenuMsgItem>
</el-main>
</el-container>
</el-main>
</el-container>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.message-item-wrapper {
position: relative;
padding-left: 8px;
border-radius: 5px;
transition: background-color 0.3s;
&.multi-select-mode {
padding-left: 32px;
}
&.is-selected {
background-color: rgba(0, 0, 0, 0.1);
}
&.is-valid-option {
cursor: pointer;
&:hover {
background-color: rgba(0, 0, 0, 0.1);
}
}
.message-checkbox {
position: absolute;
left: 8px;
top: 50%;
transform: translateY(-50%);
z-index: 1;
}
}
.message-item {
width: 100%;
margin-top: 10px;
border-radius: 5px;
display: flex;
flex-direction: column;
justify-content: center;