perf(file manager): optimize file management functions and logic for multi-file sending

This commit is contained in:
Dawn
2025-10-06 02:02:55 +08:00
parent 789e3e2c6d
commit 9629cff876
13 changed files with 438 additions and 980 deletions

2
.gitattributes vendored
View File

@@ -48,6 +48,8 @@
*.kt linguist-vendored
*.java linguist-vendored
*.py linguist-vendored
*.nsh linguist-vendored
*.nsi linguist-vendored
# 确保构建目录和依赖目录不被统计
**/target/** linguist-vendored

1
package.json vendored
View File

@@ -115,6 +115,7 @@
"lodash-es": "^4.17.21",
"mitt": "^3.0.1",
"naive-ui": "^2.43.1",
"p-limit": "^7.1.1",
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.5.0",
"pinia-shared-state": "^1.0.1",

17
pnpm-lock.yaml generated vendored
View File

@@ -116,6 +116,9 @@ importers:
naive-ui:
specifier: ^2.43.1
version: 2.43.1(vue@3.5.22(typescript@5.9.3))
p-limit:
specifier: ^7.1.1
version: 7.1.1
pinia:
specifier: ^3.0.3
version: 3.0.3(typescript@5.9.3)(vue@3.5.22(typescript@5.9.3))
@@ -3670,6 +3673,10 @@ packages:
resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
p-limit@7.1.1:
resolution: {integrity: sha512-i8PyM2JnsNChVSYWLr2BAjNoLi0BAYC+wecOnZnVV+YSNJkzP7cWmvI34dk0WArWfH9KwBHNoZI3P3MppImlIA==}
engines: {node: '>=20'}
p-locate@6.0.0:
resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -4704,6 +4711,10 @@ packages:
resolution: {integrity: sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==}
engines: {node: '>=12.20'}
yocto-queue@1.2.1:
resolution: {integrity: sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==}
engines: {node: '>=12.20'}
yoctocolors-cjs@2.1.2:
resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==}
engines: {node: '>=18'}
@@ -8046,6 +8057,10 @@ snapshots:
dependencies:
yocto-queue: 1.1.1
p-limit@7.1.1:
dependencies:
yocto-queue: 1.2.1
p-locate@6.0.0:
dependencies:
p-limit: 4.0.0
@@ -9113,6 +9128,8 @@ snapshots:
yocto-queue@1.1.1: {}
yocto-queue@1.2.1: {}
yoctocolors-cjs@2.1.2: {}
yoctocolors-cjs@2.1.3: {}

2
public/icon.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -389,13 +389,13 @@ pub async fn get_navigation_items() -> Result<Vec<NavigationItem>, String> {
NavigationItem {
key: "myFiles".to_string(),
label: "我的文件".to_string(),
icon: "folder".to_string(),
icon: "file".to_string(),
active: true,
},
NavigationItem {
key: "senders".to_string(),
label: "发送人".to_string(),
icon: "user".to_string(),
icon: "avatar".to_string(),
active: false,
},
NavigationItem {
@@ -407,7 +407,7 @@ pub async fn get_navigation_items() -> Result<Vec<NavigationItem>, String> {
NavigationItem {
key: "groups".to_string(),
label: "群聊".to_string(),
icon: "group".to_string(),
icon: "peoples".to_string(),
active: false,
},
];

View File

@@ -1,5 +1,5 @@
<template>
<div class="file-content flex-1 flex flex-col bg-[--right-bg-color] overflow-hidden">
<div class="min-w-0 cursor-default select-none flex-1 flex flex-col bg-[--right-bg-color] overflow-hidden">
<!-- 内容头部 -->
<div class="content-header p-20px pb-16px border-b border-solid border-[--line-color]">
<div class="header-info">
@@ -13,30 +13,33 @@
</div>
<!-- 文件列表区域 -->
<div class="file-list-container flex-1 overflow-hidden">
<div class="relative overflow-hidden">
<!-- 文件列表 -->
<n-scrollbar v-if="timeGroupedFiles.length > 0" class="file-list-scroll">
<div class="file-list-content p-20px">
<!-- 时间分组 -->
<div v-for="timeGroup in timeGroupedFiles" :key="timeGroup.date" class="time-group mb-32px">
<!-- 时间分组标题 -->
<div class="time-group-header sticky top-0 bg-[--right-bg-color] py-12px mb-16px z-10">
<!-- <div class="time-group-header sticky top-0 bg-[--right-bg-color] py-12px mb-16px z-10">
<h3 class="time-group-title text-16px font-600 text-[--text-color] m-0">
{{ timeGroup.date }}
</h3>
<div class="time-group-divider h-1px bg-[--line-color] mt-8px"></div>
</div>
</div> -->
<!-- 文件列表 -->
<div :class="['files-grid']">
<FileItem
v-for="file in timeGroup.files"
:key="file.id"
:file="file"
:search-keyword="searchKeyword"
@download="handleDownloadFile"
@open="handleOpenFile"
@click="handleFileClick" />
<div v-for="file in timeGroup.files" :key="file.id" class="flex flex-col gap-8px">
<File :body="convertToFileBody(file)" :search-keyword="searchKeyword" />
<!-- 文件元信息 -->
<div class="file-meta-info">
<div class="flex-center gap-4px">
<p>来自</p>
<p class="file-sender">{{ getUserDisplayName(file.sender?.id) }}</p>
</div>
<p class="file-time">{{ file.uploadTime }}</p>
</div>
</div>
</div>
</div>
</div>
@@ -45,9 +48,11 @@
<!-- 空状态 -->
<EmptyState v-else :icon="getEmptyStateIcon()" :title="getEmptyStateTitle()">
<template #actions>
<n-button v-if="searchKeyword" @click="clearSearch" type="primary" size="small">清除搜索</n-button>
<n-button v-if="searchKeyword" @click="clearSearch" secondary type="primary" size="small">清除搜索</n-button>
<n-button v-if="selectedUser" @click="clearUserFilter" type="default" size="small">显示全部用户</n-button>
<n-button v-if="selectedUser" @click="clearUserFilter" ghost color="#13987f" size="small">
显示全部用户
</n-button>
</template>
</EmptyState>
</div>
@@ -55,8 +60,9 @@
</template>
<script setup lang="ts">
import type { FileBody } from '@/services/types'
import { useGroupStore } from '@/stores/group'
import EmptyState from './EmptyState.vue'
import FileItem from './FileItem.vue'
interface TimeGroup {
date: string
@@ -79,16 +85,27 @@ interface FileManagerState {
setSelectedUser: (userId: string) => void
}
const groupStore = useGroupStore()
const fileManagerState = inject<FileManagerState>('fileManagerState')!
const { timeGroupedFiles, searchKeyword, activeNavigation, selectedUser, userList, setSearchKeyword, setSelectedUser } =
fileManagerState
// 根据 uid 获取用户显示名称
const getUserDisplayName = (uid: string) => {
const groupName = groupStore.getUserDisplayName(uid)
if (groupName) {
return groupName
}
return '未知用户'
}
// 获取内容标题
const getContentTitle = () => {
const navigationTitles: { [key: string]: string } = {
myFiles: '我的文件',
senders: '按发送人分类',
sessions: '按会话分类'
sessions: '按会话分类',
groups: '按群组分类'
}
return navigationTitles[activeNavigation.value] || '文件列表'
@@ -157,30 +174,17 @@ const clearUserFilter = () => {
setSelectedUser('')
}
// 处理文件下载
const handleDownloadFile = (file: any) => {
console.log('下载文件:', file.fileName)
// TODO: 实现文件下载逻辑
}
// 处理文件打开
const handleOpenFile = (file: any) => {
console.log('打开文件:', file.fileName)
// TODO: 实现文件打开逻辑
}
// 处理文件点击
const handleFileClick = (file: any) => {
console.log('点击文件:', file.fileName)
// TODO: 实现文件详情显示逻辑
// 转换文件数据为 FileBody 格式
const convertToFileBody = (file: any): FileBody => {
return {
fileName: file.fileName || '',
size: file.fileSize || 0,
url: file.url || file.downloadUrl || ''
}
}
</script>
<style scoped lang="scss">
.file-content {
min-width: 0; // 确保flex子元素能够正确缩放
}
.content-header {
flex-shrink: 0;
}
@@ -189,10 +193,6 @@ const handleFileClick = (file: any) => {
line-height: 1.2;
}
.file-list-container {
position: relative;
}
.loading-state {
display: flex;
align-items: center;
@@ -238,6 +238,28 @@ const handleFileClick = (file: any) => {
gap: 16px;
}
.file-meta-info {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 4px;
font-size: 12px;
color: #909090;
}
.file-sender {
color: #13987f;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.file-time {
opacity: 0.8;
}
.empty-state {
display: flex;
align-items: center;

View File

@@ -1,498 +0,0 @@
<template>
<div
:class="[
'file-item',
`file-item--${file.status}`,
{
'file-item--downloading': file.status === 'downloading',
'file-item--mobile': isMobile
}
]"
@dblclick="handleDoubleClick"
@click="handleClick">
<!-- 文件缩略图 -->
<div class="file-thumbnail">
<!-- 图片文件显示实际预览 -->
<img
v-if="isImageFile && file.thumbnailUrl"
:src="file.thumbnailUrl"
:alt="file.fileName"
class="thumbnail-img"
@error="handleThumbnailError" />
<!-- 视频文件显示视频预览 -->
<div v-else-if="isVideoFile && file.thumbnailUrl" class="video-thumbnail">
<video :src="file.thumbnailUrl" class="video-preview" preload="metadata" @error="handleThumbnailError">
<source :src="file.thumbnailUrl" />
</video>
<div class="video-overlay">
<svg class="play-icon size-20px text-white">
<use href="#play"></use>
</svg>
</div>
</div>
<!-- 其他文件显示对应图标 -->
<img
v-else
:src="`/file/${fileIconName}.svg`"
:alt="`${fileIconName} 文件`"
class="file-type-icon"
@error="handleIconError" />
<!-- 状态覆盖层 -->
<div v-if="file.status === 'uploading' || file.status === 'downloading'" class="status-overlay">
<n-progress
type="circle"
:percentage="progressPercentage"
:size="24"
:stroke-width="3"
:show-indicator="false"
color="#13987f" />
</div>
</div>
<!-- 文件信息 -->
<div class="file-info">
<!-- 文件名 -->
<div class="file-name" :title="file.fileName">
<n-highlight
v-if="searchKeyword"
:text="truncateFileName(file.fileName)"
:patterns="[searchKeyword]"
:highlight-style="{
padding: '0 4px',
borderRadius: '4px',
color: '#000',
background: '#13987f'
}" />
<template v-else>
{{ truncateFileName(file.fileName) }}
</template>
</div>
<!-- 文件大小和状态 -->
<div class="file-meta">
<span class="file-size">{{ formatFileSize(file.fileSize) }}</span>
<span :class="['file-status', `file-status--${file.status}`]">
{{ getStatusText(file.status) }}
</span>
</div>
</div>
<!-- 发送者信息和时间 -->
<div class="file-details">
<div class="send-time">{{ formatTimestamp(new Date(file.uploadTime).getTime()) }}</div>
<div class="sender-info">
<span class="sender-name">{{ file.sender.name }}</span>
</div>
</div>
<!-- 操作按钮 -->
<div class="file-actions" v-show="showActions">
<n-button
v-if="file.status === 'completed' && !file.isDownloaded"
size="small"
type="primary"
@click.stop="handleDownload">
下载
</n-button>
<n-button
v-if="file.status === 'completed' && file.isDownloaded"
size="small"
type="default"
@click.stop="handleOpenFile">
打开
</n-button>
<n-button v-if="file.status === 'expired'" size="small" type="error" disabled>已过期</n-button>
</div>
</div>
</template>
<script setup lang="ts">
import { formatTimestamp } from '@/utils/ComputedTime'
import { getFileExtension, SUPPORTED_IMAGE_EXTENSIONS, SUPPORTED_VIDEO_EXTENSIONS } from '@/utils/FileType'
import { formatBytes, getFileSuffix } from '@/utils/Formatting'
interface FileInfo {
id?: string
fileName: string
fileType: string
fileSize: number
status: string
uploadTime: string
isDownloaded: boolean
thumbnailUrl?: string
sender: {
name: string
}
}
interface Props {
file: FileInfo
searchKeyword?: string
isMobile?: boolean
}
interface Emits {
(e: 'download', file: FileInfo): void
(e: 'open', file: FileInfo): void
(e: 'click', file: FileInfo): void
}
const props = withDefaults(defineProps<Props>(), {
isMobile: false
})
const emit = defineEmits<Emits>()
// 显示操作按钮
const showActions = ref(false)
// 下载进度(模拟)
const progressPercentage = ref(0)
// 文件类型判断
const isImageFile = computed(() => {
const fileExtension = props.file.fileType?.toLowerCase() || getFileExtension(props.file.fileName)
return SUPPORTED_IMAGE_EXTENSIONS.includes(fileExtension as any)
})
const isVideoFile = computed(() => {
const fileExtension = props.file.fileType?.toLowerCase() || getFileExtension(props.file.fileName)
return SUPPORTED_VIDEO_EXTENSIONS.includes(fileExtension as any)
})
// 获取文件图标名称
const fileIconName = computed(() => {
return getFileSuffix(props.file.fileName)
})
// 获取状态文本
const getStatusText = (status: string) => {
const statusMap: { [key: string]: string } = {
uploading: '上传中',
completed: props.file.isDownloaded ? '已下载' : '未下载',
expired: '已过期',
downloading: '下载中'
}
return statusMap[status] || '未知'
}
// 格式化文件大小
const formatFileSize = (size: number) => {
return formatBytes(size)
}
// 截断文件名
const truncateFileName = (fileName: string, maxLength = 30) => {
if (fileName.length <= maxLength) {
return fileName
}
const extension = fileName.split('.').pop() || ''
const nameWithoutExt = fileName.substring(0, fileName.lastIndexOf('.'))
const maxNameLength = maxLength - extension.length - 3 // 3 for '...'
return `${nameWithoutExt.substring(0, maxNameLength)}...${extension}`
}
// 处理双击
const handleDoubleClick = () => {
if (props.file.status === 'completed') {
handleOpenFile()
}
}
// 处理点击
const handleClick = () => {
emit('click', props.file)
}
// 处理下载
const handleDownload = () => {
emit('download', props.file)
}
// 处理打开文件
const handleOpenFile = () => {
emit('open', props.file)
}
// 处理缩略图错误
const handleThumbnailError = (event: Event) => {
const target = event.target as HTMLImageElement
target.style.display = 'none'
}
// 处理图标错误
const handleIconError = (event: Event) => {
const target = event.target as HTMLImageElement
const currentSrc = target.src
// 防止无限循环:如果已经是默认图标,就不再更改
if (currentSrc.includes('/file/other.svg')) {
return
}
// 回退到默认文件图标
target.src = '/file/other.svg'
}
// 模拟下载进度
watch(
() => props.file.status,
(newStatus) => {
if (newStatus === 'downloading') {
const interval = setInterval(() => {
progressPercentage.value += 10
if (progressPercentage.value >= 100) {
clearInterval(interval)
progressPercentage.value = 0
}
}, 100)
}
}
)
</script>
<style scoped lang="scss">
.file-item {
display: grid;
grid-template-columns: 64px 1fr auto;
grid-template-rows: auto auto;
gap: 12px 16px;
padding: 16px;
border-radius: 8px;
background-color: var(--file-bg-color);
border: 1px solid transparent;
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
&:hover {
background-color: var(--bg-bubble-active);
border-color: var(--line-color);
.file-actions {
opacity: 1;
visibility: visible;
}
}
&--downloading,
&--uploading {
opacity: 0.8;
}
&--expired {
opacity: 0.6;
.file-info,
.file-details {
color: var(--text-color);
opacity: 0.6;
}
}
// 移动端适配
&--mobile {
grid-template-columns: 48px 1fr;
grid-template-rows: auto auto auto;
gap: 8px 12px;
padding: 12px;
.file-thumbnail {
width: 48px;
height: 48px;
grid-row: 1 / 3;
}
.file-details {
grid-column: 1 / 3;
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
}
.file-actions {
grid-column: 1 / 3;
justify-content: flex-start;
}
}
}
.file-thumbnail {
grid-row: 1 / 3;
position: relative;
width: 64px;
height: 64px;
border-radius: 6px;
overflow: hidden;
background-color: var(--bg-bubble);
display: flex;
align-items: center;
justify-content: center;
}
.thumbnail-img,
.file-type-icon {
width: 100%;
height: 100%;
object-fit: cover;
}
.file-type-icon {
object-fit: contain;
padding: 8px;
}
.video-thumbnail {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.video-preview {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 6px;
}
.video-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: rgba(0, 0, 0, 0.6);
border-radius: 50%;
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.play-icon {
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3));
}
.status-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
}
.file-info {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.file-name {
font-size: 14px;
font-weight: 500;
color: var(--text-color);
line-height: 1.4;
word-break: break-word;
}
.file-meta {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
}
.file-size {
color: #909090;
}
.file-status {
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 500;
&--completed {
background-color: rgba(19, 152, 127, 0.1);
color: #13987f;
}
&--uploading,
&--downloading {
background-color: rgba(24, 144, 255, 0.1);
color: #1890ff;
}
&--expired {
background-color: rgba(255, 77, 79, 0.1);
color: #ff4d4f;
}
}
.file-details {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
text-align: right;
}
.send-time {
font-size: 12px;
color: #909090;
}
.sender-info {
font-size: 12px;
}
.sender-name {
color: #13987f;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.file-actions {
grid-column: 1 / 4;
display: flex;
gap: 8px;
justify-content: flex-end;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
}
// 深色主题适配
html[data-theme='dark'] {
.file-item {
&:hover {
background-color: var(--bg-bubble-active);
}
}
.file-thumbnail {
background-color: var(--bg-bubble);
}
}
</style>

View File

@@ -17,7 +17,7 @@
<use :href="`#${item.icon}`"></use>
</svg>
</div>
<span class="navigation-item__label">{{ item.label }}</span>
<span class="text-14px">{{ item.label }}</span>
</div>
</div>
</div>
@@ -79,7 +79,6 @@ const handleNavigationClick = (key: string) => {
&--active {
background-color: rgba(19, 152, 127, 0.2);
color: #13987f;
font-weight: 600;
.navigation-item__icon svg {
color: #13987f;
@@ -98,11 +97,6 @@ const handleNavigationClick = (key: string) => {
}
}
.navigation-item__label {
font-size: 14px;
font-weight: inherit;
}
// 响应式适配
@media (max-width: 800px) {
.side-navigation {

View File

@@ -94,8 +94,8 @@ const handleAvatarError = (event: Event) => {
.user-item {
display: flex;
align-items: center;
padding: 12px;
border-radius: 8px;
padding: 10px 8px;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
@@ -106,7 +106,7 @@ const handleAvatarError = (event: Event) => {
&--selected {
background-color: #e8f4f1;
border: 1px solid #13987f;
box-shadow: inset 0 0 0 1px #13987f;
}
}

View File

@@ -10,7 +10,12 @@
:placeholder="getSearchPlaceholder()"
:input-props="{ spellcheck: false }"
clearable
class="search-input"
spellCheck="false"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
class="rounded-6px border-(solid 1px [--line-color]) w-full relative text-12px"
size="small"
@input="handleSearch">
<template #prefix>
<svg class="size-16px text-[--text-color] opacity-60">
@@ -22,13 +27,13 @@
</div>
<!-- 动态内容区域 -->
<div class="content-section flex-1 px-16px overflow-hidden">
<div class="content-section flex-1 px-8px overflow-hidden">
<div class="section-title mb-12px">
<span class="text-14px font-500 text-[--text-color]">{{ getSectionTitle() }}</span>
</div>
<n-scrollbar class="content-scroll">
<div class="content-list">
<n-scrollbar style="height: calc(100vh / var(--page-scale, 1) - 34px)">
<div class="pr-8px">
<!-- 全部选项 -->
<UserItem
:user="getAllOption()"

View File

@@ -1,6 +1,7 @@
import { Channel, invoke } from '@tauri-apps/api/core'
import { readImage, readText } from '@tauri-apps/plugin-clipboard-manager'
import { useDebounceFn } from '@vueuse/core'
import pLimit from 'p-limit'
import { storeToRefs } from 'pinia'
import type { Ref } from 'vue'
import { LimitEnum, MessageStatusEnum, MittEnum, MsgEnum, TauriCommand, UploadSceneEnum } from '@/enums'
@@ -314,13 +315,13 @@ export const useMsgInput = (messageInputDom: Ref) => {
const doc = parseHtmlSafely(html)
if (!doc || !doc.body) {
let sanitized = html;
let previous;
let sanitized = html
let previous
do {
previous = sanitized;
sanitized = sanitized.replace(/<[^>]*>/g, '');
} while (sanitized !== previous);
return sanitized.trim();
previous = sanitized
sanitized = sanitized.replace(/<[^>]*>/g, '')
} while (sanitized !== previous)
return sanitized.trim()
}
const replyDiv = doc.querySelector('#replyDiv')
@@ -363,6 +364,7 @@ export const useMsgInput = (messageInputDom: Ref) => {
/** 处理发送信息事件 */
// TODO 输入框中的内容当我切换消息的时候需要记录之前输入框的内容 (nyh -> 2024-03-01 07:03:43)
const send = async () => {
const targetRoomId = globalStore.currentSession!.roomId
// 判断输入框中的图片或者文件数量是否超过限制
if (messageInputDom.value.querySelectorAll('img').length > LimitEnum.COM_COUNT) {
window.$message.warning(`一次性只能上传${LimitEnum.COM_COUNT}个文件或图片`)
@@ -513,7 +515,7 @@ export const useMsgInput = (messageInputDom: Ref) => {
await invoke(TauriCommand.SEND_MSG, {
data: {
id: tempMsgId,
roomId: globalStore.currentSession!.roomId,
roomId: targetRoomId,
msgType: msg.type,
body: messageBody
},
@@ -522,7 +524,7 @@ export const useMsgInput = (messageInputDom: Ref) => {
})
// 更新会话最后活动时间
chatStore.updateSessionLastActiveTime(globalStore.currentSession!.roomId)
chatStore.updateSessionLastActiveTime(targetRoomId)
// 消息发送成功后释放预览URL
if ((msg.type === MsgEnum.IMAGE || msg.type === MsgEnum.EMOJI) && msg.url.startsWith('blob:')) {
@@ -738,6 +740,270 @@ export const useMsgInput = (messageInputDom: Ref) => {
}
}
// ==================== 视频文件处理函数 ====================
const processVideoFile = async (
file: File,
tempMsgId: string,
messageStrategy: any,
targetRoomId: string
): Promise<void> => {
const tempMsg = messageStrategy.buildMessageType(
tempMsgId,
{
url: URL.createObjectURL(file),
size: file.size,
fileName: file.name,
thumbUrl: '',
thumbWidth: 300,
thumbHeight: 150,
thumbSize: 0
},
globalStore,
userUid
)
tempMsg.message.roomId = targetRoomId
tempMsg.message.status = MessageStatusEnum.SENDING
chatStore.pushMsg(tempMsg)
let isProgressActive = true
const cleanup = () => {
isProgressActive = false
}
try {
const [videoPath, thumbnailFile] = await Promise.all([
saveCacheFile(file, 'video/'),
messageStrategy.getVideoThumbnail(file)
])
const localThumbUrl = URL.createObjectURL(thumbnailFile)
chatStore.updateMsg({
msgId: tempMsgId,
status: MessageStatusEnum.SENDING,
body: { ...tempMsg.message.body, thumbUrl: localThumbUrl, thumbSize: thumbnailFile.size }
})
const videoUploadResult = await messageStrategy.uploadFile(videoPath, { provider: UploadProviderEnum.QINIU })
const qiniuConfig = videoUploadResult.config
const { progress, onChange } = messageStrategy.getUploadProgress()
onChange((event: string) => {
if (!isProgressActive || event !== 'progress') return
chatStore.updateMsg({
msgId: tempMsgId,
status: MessageStatusEnum.SENDING,
uploadProgress: progress.value
})
})
const [videoUploadResponse, thumbnailUploadResponse] = await Promise.all([
messageStrategy.doUpload(videoPath, videoUploadResult.uploadUrl, {
provider: UploadProviderEnum.QINIU,
...qiniuConfig
}),
uploadToQiniu(thumbnailFile, qiniuConfig.scene || 'CHAT', qiniuConfig, true)
])
cleanup()
const finalVideoUrl = videoUploadResponse?.qiniuUrl || videoUploadResult.downloadUrl
const finalThumbnailUrl =
thumbnailUploadResponse?.downloadUrl || `${qiniuConfig.domain}/${thumbnailUploadResponse?.key}`
const successChannel = new Channel<any>()
const errorChannel = new Channel<string>()
successChannel.onmessage = (message) => {
chatStore.updateMsg({
msgId: tempMsgId,
status: MessageStatusEnum.SUCCESS,
newMsgId: message.message.id,
body: message.message.body,
timeBlock: message.timeBlock
})
useMitt.emit(MittEnum.CHAT_SCROLL_BOTTOM)
}
errorChannel.onmessage = () => {
chatStore.updateMsg({ msgId: tempMsgId, status: MessageStatusEnum.FAILED })
useMitt.emit(MittEnum.CHAT_SCROLL_BOTTOM)
}
await invoke(TauriCommand.SEND_MSG, {
data: {
id: tempMsgId,
roomId: targetRoomId,
msgType: MsgEnum.VIDEO,
body: {
url: finalVideoUrl,
size: file.size,
fileName: file.name,
thumbUrl: finalThumbnailUrl,
thumbWidth: 300,
thumbHeight: 150,
thumbSize: thumbnailFile.size,
localPath: videoPath,
senderUid: userUid.value
}
},
successChannel,
errorChannel
})
URL.revokeObjectURL(tempMsg.message.body.url)
URL.revokeObjectURL(localThumbUrl)
} catch (error) {
cleanup()
throw error
}
}
// ==================== 图片文件处理函数 ====================
const processImageFile = async (
file: File,
tempMsgId: string,
messageStrategy: any,
targetRoomId: string
): Promise<void> => {
const msg = await messageStrategy.getMsg('', reply, [file])
const messageBody = messageStrategy.buildMessageBody(msg, reply)
const tempMsg = messageStrategy.buildMessageType(tempMsgId, messageBody, globalStore, userUid)
tempMsg.message.roomId = targetRoomId
tempMsg.message.status = MessageStatusEnum.SENDING
chatStore.pushMsg(tempMsg)
const { uploadUrl, downloadUrl, config } = await messageStrategy.uploadFile(msg.path, {
provider: UploadProviderEnum.QINIU
})
const doUploadResult = await messageStrategy.doUpload(msg.path, uploadUrl, config)
messageBody.url = config?.provider === UploadProviderEnum.QINIU ? doUploadResult?.qiniuUrl : downloadUrl
delete messageBody.path
chatStore.updateMsg({
msgId: tempMsgId,
body: messageBody,
status: MessageStatusEnum.SENDING
})
const successChannel = new Channel<any>()
const errorChannel = new Channel<string>()
successChannel.onmessage = (message) => {
chatStore.updateMsg({
msgId: tempMsgId,
status: MessageStatusEnum.SUCCESS,
newMsgId: message.message.id,
body: message.message.body,
timeBlock: message.timeBlock
})
useMitt.emit(MittEnum.CHAT_SCROLL_BOTTOM)
}
errorChannel.onmessage = () => {
chatStore.updateMsg({ msgId: tempMsgId, status: MessageStatusEnum.FAILED })
useMitt.emit(MittEnum.CHAT_SCROLL_BOTTOM)
}
await invoke(TauriCommand.SEND_MSG, {
data: {
id: tempMsgId,
roomId: targetRoomId,
msgType: MsgEnum.IMAGE,
body: messageBody
},
successChannel,
errorChannel
})
URL.revokeObjectURL(msg.url)
chatStore.updateSessionLastActiveTime(targetRoomId)
}
// ==================== 通用文件处理函数 ====================
const processGenericFile = async (
file: File,
tempMsgId: string,
messageStrategy: any,
targetRoomId: string
): Promise<void> => {
const msg = await messageStrategy.getMsg('', reply, [file])
const messageBody = messageStrategy.buildMessageBody(msg, reply)
const tempMsg = messageStrategy.buildMessageType(tempMsgId, { ...messageBody, url: '' }, globalStore, userUid)
tempMsg.message.roomId = targetRoomId
tempMsg.message.status = MessageStatusEnum.SENDING
chatStore.pushMsg(tempMsg)
let isProgressActive = true
const cleanup = () => {
isProgressActive = false
}
try {
const { progress, onChange } = messageStrategy.getUploadProgress()
onChange((event: string) => {
if (!isProgressActive || event !== 'progress') return
chatStore.updateMsg({
msgId: tempMsgId,
status: MessageStatusEnum.SENDING,
uploadProgress: progress.value
})
})
const { uploadUrl, downloadUrl, config } = await messageStrategy.uploadFile(msg.path, {
provider: UploadProviderEnum.QINIU
})
const doUploadResult = await messageStrategy.doUpload(msg.path, uploadUrl, config)
cleanup()
messageBody.url = config?.provider === UploadProviderEnum.QINIU ? doUploadResult?.qiniuUrl : downloadUrl
delete messageBody.path
chatStore.updateMsg({
msgId: tempMsgId,
body: messageBody,
status: MessageStatusEnum.SENDING
})
const successChannel = new Channel<any>()
const errorChannel = new Channel<string>()
successChannel.onmessage = (message) => {
chatStore.updateMsg({
msgId: tempMsgId,
status: MessageStatusEnum.SUCCESS,
newMsgId: message.message.id,
body: message.message.body,
timeBlock: message.timeBlock
})
useMitt.emit(MittEnum.CHAT_SCROLL_BOTTOM)
}
errorChannel.onmessage = () => {
chatStore.updateMsg({ msgId: tempMsgId, status: MessageStatusEnum.FAILED })
useMitt.emit(MittEnum.CHAT_SCROLL_BOTTOM)
}
await invoke(TauriCommand.SEND_MSG, {
data: {
id: tempMsgId,
roomId: targetRoomId,
msgType: MsgEnum.FILE,
body: messageBody
},
successChannel,
errorChannel
})
chatStore.updateSessionLastActiveTime(targetRoomId)
} catch (error) {
cleanup()
throw error
}
}
onMounted(async () => {
useMitt.on(MittEnum.RE_EDIT, async (event: string) => {
messageInputDom.value.focus()
@@ -834,425 +1100,84 @@ export const useMsgInput = (messageInputDom: Ref) => {
})
/**
* 发送文件的函数
* 发送文件的函数(优化版 - 并发处理,逐个显示)
* @param files 要发送的文件数组
*/
const sendFilesDirect = async (files: File[]) => {
const targetRoomId = globalStore.currentSession!.roomId
// 初始化文件上传队列
globalFileUploadQueue.initQueue(files)
// 同步处理个文件,等待前一个完成再处理下一个
for (let i = 0; i < files.length; i++) {
const file = files[i]
const fileId = globalFileUploadQueue.queue.items[i]?.id
// 创建并发限制器(同时处理3个文件
const limit = pLimit(3)
// 将变量声明移到更高的作用域避免catch块中无法访问
let msgType: MsgEnum = MsgEnum.TEXT
let tempMsgId = ''
let progressUnsubscribe: (() => void) | null = null
// 并发处理所有文件
const tasks = files.map((file, index) => {
const fileId = globalFileUploadQueue.queue.items[index]?.id
try {
// 更新当前文件为上传中状态
if (fileId) {
globalFileUploadQueue.updateFileStatus(fileId, 'uploading', 0)
}
return limit(async () => {
let msgType: MsgEnum = MsgEnum.TEXT
let tempMsgId = ''
// 判断文件类型和修复MIME类型
const processedFile = fixFileMimeType(file)
msgType = getMessageTypeByFile(processedFile)
// 对音频文件进行特殊处理:通过文件选择的方式发送,作为文件类型处理
if (msgType === MsgEnum.VOICE) {
msgType = MsgEnum.FILE
}
// 生成唯一消息ID避免重复
tempMsgId = `${Date.now()}_${Math.random().toString(36).substring(2, 11)}`
const messageStrategy = messageStrategyMap[msgType]
if (msgType === MsgEnum.VIDEO) {
// 视频文件处理逻辑
const tempMsg = messageStrategy.buildMessageType(
tempMsgId,
{
url: URL.createObjectURL(processedFile),
size: processedFile.size,
fileName: processedFile.name,
thumbUrl: '',
thumbWidth: 300,
thumbHeight: 150,
thumbSize: 0
},
globalStore,
userUid
)
tempMsg.message.status = MessageStatusEnum.SENDING
// 异步处理上传
const videoPath = await saveCacheFile(processedFile, 'video/')
// 直接使用 VideoMessageStrategy 生成缩略图,避免重复处理
const videoStrategy = messageStrategy as any
const thumbnailFile = await videoStrategy.getVideoThumbnail(processedFile)
// 生成本地缩略图预览URL立即更新消息显示
const localThumbUrl = URL.createObjectURL(thumbnailFile)
chatStore.updateMsg({
msgId: tempMsgId,
status: MessageStatusEnum.SENDING,
body: {
...tempMsg.message.body,
thumbUrl: localThumbUrl,
thumbSize: thumbnailFile.size
}
})
// 获取一次七牛云配置,共享使用
const videoUploadResult = await messageStrategy.uploadFile(videoPath, { provider: UploadProviderEnum.QINIU })
const qiniuConfig = videoUploadResult.config // 使用第一次获取的配置
// 更新状态为上传中
chatStore.updateMsg({
msgId: tempMsgId,
status: MessageStatusEnum.SENDING,
uploadProgress: 0
})
// 获取视频策略的上传进度监听
const { progress, onChange } = (messageStrategy as any).getUploadProgress()
// 使用标志来控制事件处理
let isProgressActive = true
// 监听上传进度并实时更新消息
const handleProgress = (event: string) => {
if (!isProgressActive) return // 如果已经取消,不处理事件
if (event === 'progress') {
console.log(`🔄 视频上传进度更新: ${progress.value}% (消息ID: ${tempMsgId})`)
chatStore.updateMsg({
msgId: tempMsgId,
status: MessageStatusEnum.SENDING,
uploadProgress: progress.value
})
}
try {
// 更新队列状态
if (fileId) {
globalFileUploadQueue.updateFileStatus(fileId, 'uploading', 0)
}
// 添加监听器
onChange(handleProgress)
// 文件类型处理
const processedFile = fixFileMimeType(file)
msgType = getMessageTypeByFile(processedFile)
if (msgType === MsgEnum.VOICE) msgType = MsgEnum.FILE
// 创建取消函数
progressUnsubscribe = () => {
isProgressActive = false
console.log(`🗑️ 清理进度监听器 (消息ID: ${tempMsgId})`)
// 生成唯一消息ID
tempMsgId = `${Date.now()}_${Math.random().toString(36).substring(2, 11)}`
const messageStrategy = messageStrategyMap[msgType]
// 根据类型调用对应处理函数,传递固定的 roomId
if (msgType === MsgEnum.VIDEO) {
await processVideoFile(processedFile, tempMsgId, messageStrategy, targetRoomId)
} else if (msgType === MsgEnum.IMAGE) {
await processImageFile(processedFile, tempMsgId, messageStrategy, targetRoomId)
} else if (msgType === MsgEnum.FILE) {
await processGenericFile(processedFile, tempMsgId, messageStrategy, targetRoomId)
}
let videoUploadResponse: any = null
try {
// 上传视频
videoUploadResponse = await messageStrategy.doUpload(videoPath, videoUploadResult.uploadUrl, {
provider: UploadProviderEnum.QINIU,
...qiniuConfig
// 成功 - 更新队列状态
if (fileId) {
globalFileUploadQueue.updateFileStatus(fileId, 'completed', 100)
}
} catch (error) {
console.error(`${file.name} 发送失败:`, error)
// 失败 - 更新队列和消息状态
if (fileId) {
globalFileUploadQueue.updateFileStatus(fileId, 'failed', 0)
}
if (tempMsgId) {
chatStore.updateMsg({
msgId: tempMsgId,
status: MessageStatusEnum.FAILED
})
// 清理进度监听器
if (progressUnsubscribe) {
progressUnsubscribe()
progressUnsubscribe = null
}
} catch (uploadError) {
// 清理进度监听器
if (progressUnsubscribe) {
progressUnsubscribe()
progressUnsubscribe = null
}
throw uploadError
}
// 直接使用七牛云上传缩略图避免通过doUpload路径
const thumbnailUploadResponse = await uploadToQiniu(
thumbnailFile,
qiniuConfig.scene || 'CHAT',
qiniuConfig,
true // 是否启用文件去重
)
const finalVideoUrl = videoUploadResponse?.qiniuUrl || videoUploadResult.downloadUrl
const finalThumbnailUrl =
thumbnailUploadResponse?.downloadUrl || `${qiniuConfig.domain}/${thumbnailUploadResponse?.key}`
// 发送消息到服务器保存 - 使用 channel 方式
const videoSuccessChannel = new Channel<any>()
const videoErrorChannel = new Channel<string>()
// 监听成功响应
videoSuccessChannel.onmessage = (message) => {
console.log('[视频] 收到 send_msg_success 响应:', message)
// 成功后才添加消息到聊天列表
const finalMsg = messageStrategy.buildMessageType(
message.message.id,
message.message.body,
globalStore,
userUid
)
finalMsg.message.status = MessageStatusEnum.SUCCESS
finalMsg.timeBlock = message.timeBlock
chatStore.pushMsg(finalMsg)
useMitt.emit(MittEnum.CHAT_SCROLL_BOTTOM)
}
// 监听错误响应
videoErrorChannel.onmessage = (msgId) => {
console.log('[视频] 收到 send_msg_error 响应:', msgId)
useMitt.emit(MittEnum.CHAT_SCROLL_BOTTOM)
}
await invoke(TauriCommand.SEND_MSG, {
data: {
id: tempMsgId,
roomId: globalStore.currentSession!.roomId,
msgType: MsgEnum.VIDEO,
body: {
url: finalVideoUrl,
size: processedFile.size,
fileName: processedFile.name,
thumbUrl: finalThumbnailUrl,
thumbWidth: 300,
thumbHeight: 150,
thumbSize: thumbnailFile.size,
localPath: videoPath, // 保存本地缓存路径
senderUid: userUid.value // 保存发送者UID
}
},
successChannel: videoSuccessChannel,
errorChannel: videoErrorChannel
})
// 清理本地URL
URL.revokeObjectURL(tempMsg.message.body.url)
URL.revokeObjectURL(localThumbUrl)
} else if (msgType === MsgEnum.IMAGE) {
// 图片文件处理逻辑
// 直接通过fileList参数传递文件ImageMessageStrategy会处理文件缓存和预览URL
const msg = await messageStrategy.getMsg('', reply, [processedFile])
const messageBody = messageStrategy.buildMessageBody(msg, reply)
// 创建临时消息对象使用ImageStrategy提供的预览URL
const tempMsg = messageStrategy.buildMessageType(tempMsgId, messageBody, globalStore, userUid)
tempMsg.message.status = MessageStatusEnum.SENDING
console.log('🖼️ 开始处理图片上传:', processedFile.name)
// 上传图片
const { uploadUrl, downloadUrl, config } = await messageStrategy.uploadFile(msg.path, {
provider: UploadProviderEnum.QINIU
})
const doUploadResult = await messageStrategy.doUpload(msg.path, uploadUrl, config)
// 更新消息体中的URL为服务器URL
messageBody.url =
config?.provider && config?.provider === UploadProviderEnum.QINIU ? doUploadResult?.qiniuUrl : downloadUrl
delete messageBody.path // 删除临时路径
// 更新临时消息的URL
chatStore.updateMsg({
msgId: tempMsgId,
body: {
...messageBody
},
status: MessageStatusEnum.SENDING
})
console.log('🖼️ 图片上传完成更新为服务器URL:', messageBody.url)
// 发送消息到服务器 - 使用 channel 方式
const imageSuccessChannel = new Channel<any>()
const imageErrorChannel = new Channel<string>()
// 监听成功响应
imageSuccessChannel.onmessage = (message) => {
console.log('[图片] 收到 send_msg_success 响应:', message)
// 成功后才添加消息到聊天列表
const finalMsg = messageStrategy.buildMessageType(
message.message.id,
message.message.body,
globalStore,
userUid
)
finalMsg.message.status = MessageStatusEnum.SUCCESS
finalMsg.timeBlock = message.timeBlock
chatStore.pushMsg(finalMsg)
useMitt.emit(MittEnum.CHAT_SCROLL_BOTTOM)
}
// 监听错误响应
imageErrorChannel.onmessage = (msgId) => {
console.log('[图片] 收到 send_msg_error 响应:', msgId)
useMitt.emit(MittEnum.CHAT_SCROLL_BOTTOM)
}
await invoke(TauriCommand.SEND_MSG, {
data: {
id: tempMsgId,
roomId: globalStore.currentSession!.roomId,
msgType: MsgEnum.IMAGE,
body: messageBody
},
successChannel: imageSuccessChannel,
errorChannel: imageErrorChannel
})
// 更新会话最后活动时间
chatStore.updateSessionLastActiveTime(globalStore.currentSession!.roomId)
// 释放本地预览URL
URL.revokeObjectURL(msg.url)
} else if (msgType === MsgEnum.FILE) {
// 文件处理逻辑(包括被重分类为文件的音频)
const msg = await messageStrategy.getMsg('', reply, [processedFile])
const messageBody = messageStrategy.buildMessageBody(msg, reply)
// 创建临时消息对象
const tempMsg = messageStrategy.buildMessageType(
tempMsgId,
{
...messageBody,
url: '' // 文件URL上传后会被设置
},
globalStore,
userUid
)
tempMsg.message.status = MessageStatusEnum.SENDING
// 获取上传进度监听
const { progress, onChange } = (messageStrategy as any).getUploadProgress()
// 使用标志来控制事件处理
let isProgressActive = true
// 监听上传进度并实时更新消息
const handleProgress = (event: string) => {
if (!isProgressActive) return // 如果已经取消,不处理事件
if (event === 'progress') {
console.log(`🔄 文件上传进度更新: ${progress.value}% (消息ID: ${tempMsgId})`)
chatStore.updateMsg({
msgId: tempMsgId,
status: MessageStatusEnum.SENDING,
uploadProgress: progress.value
})
}
}
// 添加监听器
onChange(handleProgress)
// 创建取消函数
progressUnsubscribe = () => {
isProgressActive = false
console.log(`🗑️ 清理文件上传进度监听器 (消息ID: ${tempMsgId})`)
}
// 上传文件
const { uploadUrl, downloadUrl, config } = await messageStrategy.uploadFile(msg.path, {
provider: UploadProviderEnum.QINIU
})
const doUploadResult = await messageStrategy.doUpload(msg.path, uploadUrl, config)
// 更新消息体中的URL为服务器URL
messageBody.url =
config?.provider && config?.provider === UploadProviderEnum.QINIU ? doUploadResult?.qiniuUrl : downloadUrl
delete messageBody.path // 删除临时路径
// 更新临时消息的URL
chatStore.updateMsg({
msgId: tempMsgId,
body: {
...messageBody
},
status: MessageStatusEnum.SENDING
})
console.log('📎 文件上传完成更新为服务器URL:', messageBody.url)
// 发送消息到服务器 - 使用 channel 方式
const fileSuccessChannel = new Channel<any>()
const fileErrorChannel = new Channel<string>()
// 监听成功响应
fileSuccessChannel.onmessage = (message) => {
console.log('[文件] 收到 send_msg_success 响应:', message)
// 成功后才添加消息到聊天列表
const finalMsg = messageStrategy.buildMessageType(
message.message.id,
message.message.body,
globalStore,
userUid
)
finalMsg.message.status = MessageStatusEnum.SUCCESS
finalMsg.timeBlock = message.timeBlock
chatStore.pushMsg(finalMsg)
useMitt.emit(MittEnum.CHAT_SCROLL_BOTTOM)
}
// 监听错误响应
fileErrorChannel.onmessage = (msgId) => {
console.log('[文件] 收到 send_msg_error 响应:', msgId)
useMitt.emit(MittEnum.CHAT_SCROLL_BOTTOM)
}
await invoke(TauriCommand.SEND_MSG, {
data: {
id: tempMsgId,
roomId: globalStore.currentSession!.roomId,
msgType: MsgEnum.FILE,
body: messageBody
},
successChannel: fileSuccessChannel,
errorChannel: fileErrorChannel
})
// 更新会话最后活动时间
chatStore.updateSessionLastActiveTime(globalStore.currentSession!.roomId)
// console.log('📎 文件消息发送成功:', serverResponse.message.id)
// 清理进度监听器
if (progressUnsubscribe) {
progressUnsubscribe()
progressUnsubscribe = null
}
window.$message.error(`${file.name} 发送失败`)
}
})
})
// 文件上传成功,更新队列状态
if (fileId) {
globalFileUploadQueue.updateFileStatus(fileId, 'completed', 100)
}
} catch (error) {
console.error(`${msgType === MsgEnum.VIDEO ? '视频' : '文件'}发送失败:`, error)
// 等待所有文件完成不阻塞UI文件会逐个显示成功
await Promise.allSettled(tasks)
// 文件上传失败,更新队列状态
if (fileId) {
globalFileUploadQueue.updateFileStatus(fileId, 'failed', 0)
}
// 确保清理进度监听器
if (progressUnsubscribe) {
progressUnsubscribe()
progressUnsubscribe = null
}
// 失败时不显示消息,只显示错误提示
window.$message.error(`${msgType === MsgEnum.VIDEO ? '视频' : '文件'}发送失败`)
}
}
// 文件队列处理完成后,检查输入框是否有图片内容,如果有则自动发送
// 检查输入框中是否有图片需要自动发送
try {
await nextTick()
// 检查输入框中是否有图片
if (messageInputDom.value && messageInputDom.value.querySelectorAll('img').length > 0) {
if (
messageInputDom.value?.querySelectorAll('img').length > 0 &&
globalStore.currentSession!.roomId === targetRoomId
) {
const contentType = getMessageContentType(messageInputDom)
if (contentType === MsgEnum.IMAGE || contentType === MsgEnum.EMOJI) {
await send()
@@ -1263,11 +1188,8 @@ export const useMsgInput = (messageInputDom: Ref) => {
}
}
/**
* 发送语音的函数
* @param voiceData 语音数据
*/
const sendVoiceDirect = async (voiceData: any) => {
const targetRoomId = globalStore.currentSession!.roomId
try {
// 创建语音消息数据
const msg = {
@@ -1299,7 +1221,7 @@ export const useMsgInput = (messageInputDom: Ref) => {
},
message: {
id: tempMsgId,
roomId: globalStore.currentSession!.roomId,
roomId: targetRoomId,
sendTime: Date.now(),
status: MessageStatusEnum.PENDING,
type: MsgEnum.VOICE,
@@ -1345,7 +1267,7 @@ export const useMsgInput = (messageInputDom: Ref) => {
const sendData = {
id: tempMsgId,
roomId: globalStore.currentSession!.roomId,
roomId: targetRoomId,
msgType: MsgEnum.VOICE,
body: messageBody
}
@@ -1385,7 +1307,7 @@ export const useMsgInput = (messageInputDom: Ref) => {
})
// 更新会话最后活动时间
chatStore.updateSessionLastActiveTime(globalStore.currentSession!.roomId)
chatStore.updateSessionLastActiveTime(targetRoomId)
// 释放本地预览URL
if (msg.url.startsWith('asset://')) {
@@ -1415,6 +1337,7 @@ export const useMsgInput = (messageInputDom: Ref) => {
* @param locationData 地图数据
*/
const sendLocationDirect = async (locationData: any) => {
const targetRoomId = globalStore.currentSession!.roomId
try {
const tempMsgId = 'T' + Date.now().toString()
const messageStrategy = messageStrategyMap[MsgEnum.LOCATION]
@@ -1467,7 +1390,7 @@ export const useMsgInput = (messageInputDom: Ref) => {
await invoke(TauriCommand.SEND_MSG, {
data: {
id: tempMsgId,
roomId: globalStore.currentSession!.roomId,
roomId: targetRoomId,
msgType: MsgEnum.LOCATION,
body: messageBody
},
@@ -1476,7 +1399,7 @@ export const useMsgInput = (messageInputDom: Ref) => {
})
// 更新会话最后活动时间
chatStore.updateSessionLastActiveTime(globalStore.currentSession!.roomId)
chatStore.updateSessionLastActiveTime(targetRoomId)
} catch (error) {
console.error('位置消息发送失败:', error)
}

View File

@@ -526,15 +526,7 @@ export const useChatStore = defineStore(
const message = currentMessageMap.value!.get(msgId)
if (message && typeof data.recallUid === 'string') {
const cacheUser = groupStore.getUserInfo(data.recallUid)!
let recallMessageBody: string
// 如果撤回者的 id 不等于消息发送人的 id, 或者你本人就是管理员,那么显示管理员撤回的。
if (data.recallUid !== userStore.userInfo!.uid) {
recallMessageBody = `管理员"${cacheUser.name}"撤回了一条消息` // 后期根据本地用户数据修改
} else {
// 如果被撤回的消息是消息发送者撤回,正常显示
recallMessageBody = `"${cacheUser.name}"撤回了一条消息` // 后期根据本地用户数据修改
}
const recallMessageBody = `"${cacheUser.name}"撤回了一条消息`
// 更新前端缓存
message.message.type = MsgEnum.RECALL

View File

@@ -32,7 +32,6 @@ declare module 'vue' {
EmptyState: typeof import('./../components/fileManager/EmptyState.vue')['default']
File: typeof import('./../components/rightBox/renderMessage/File.vue')['default']
FileContent: typeof import('./../components/fileManager/FileContent.vue')['default']
FileItem: typeof import('./../components/fileManager/FileItem.vue')['default']
FileUploadModal: typeof import('./../components/rightBox/FileUploadModal.vue')['default']
FileUploadProgress: typeof import('./../components/rightBox/FileUploadProgress.vue')['default']
FloatBlockList: typeof import('./../components/common/FloatBlockList.vue')['default']
@@ -77,6 +76,7 @@ declare module 'vue' {
NIconWrapper: typeof import('naive-ui')['NIconWrapper']
NImage: typeof import('naive-ui')['NImage']
NImageGroup: typeof import('naive-ui')['NImageGroup']
NInfiniteScroll: typeof import('naive-ui')['NInfiniteScroll']
NInput: typeof import('naive-ui')['NInput']
NLoadingBarProvider: typeof import('naive-ui')['NLoadingBarProvider']
NMessageProvider: typeof import('naive-ui')['NMessageProvider']