perf(file manager): ⚡ optimize file management functions and logic for multi-file sending
This commit is contained in:
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -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
1
package.json
vendored
@@ -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
17
pnpm-lock.yaml
generated
vendored
@@ -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
2
public/icon.js
vendored
File diff suppressed because one or more lines are too long
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
2
src/typings/components.d.ts
vendored
2
src/typings/components.d.ts
vendored
@@ -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']
|
||||
|
||||
Reference in New Issue
Block a user