mirror of
https://github.com/HuLaSpark/HuLa.git
synced 2026-09-01 14:52:48 +08:00
feat(component): ✨ 增加文件类型(除语音、视频)等文件类型的样式兼容
This commit is contained in:
|
Before Width: | Height: | Size: 860 B After Width: | Height: | Size: 860 B |
@@ -93,6 +93,7 @@
|
||||
"notification:allow-permission-state",
|
||||
"opener:default",
|
||||
"opener:allow-reveal-item-in-dir",
|
||||
"opener:allow-open-path",
|
||||
"mic-recorder:default",
|
||||
{
|
||||
"identifier": "shell:allow-execute",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -328,14 +328,14 @@
|
||||
</n-image-group> -->
|
||||
|
||||
<!-- 消息为文件 -->
|
||||
<n-image
|
||||
<!-- <n-image
|
||||
class="select-none"
|
||||
v-if="typeof item.message.body.url === 'string' && item.message.type === MsgEnum.FILE"
|
||||
:img-props="{ style: { maxWidth: '325px', maxHeight: '165px' } }"
|
||||
show-toolbar-tooltip
|
||||
preview-disabled
|
||||
style="border-radius: 8px"
|
||||
:src="item.message.body.url"></n-image>
|
||||
:src="item.message.body.url"></n-image> -->
|
||||
<!-- 消息状态指示器 -->
|
||||
<div v-if="item.fromUser.uid === userUid" class="absolute -left-6 top-2">
|
||||
<n-icon v-if="item.message.status === MessageStatusEnum.SENDING" class="text-gray-400">
|
||||
@@ -1000,7 +1000,13 @@ const handleViewAnnouncement = () => {
|
||||
}
|
||||
|
||||
const isSpecialMsgType = (type: number) => {
|
||||
return type === MsgEnum.IMAGE || type === MsgEnum.EMOJI || type === MsgEnum.NOTICE || type === MsgEnum.VIDEO
|
||||
return (
|
||||
type === MsgEnum.IMAGE ||
|
||||
type === MsgEnum.EMOJI ||
|
||||
type === MsgEnum.NOTICE ||
|
||||
type === MsgEnum.VIDEO ||
|
||||
type === MsgEnum.FILE
|
||||
)
|
||||
}
|
||||
|
||||
// 判断表情反应是否只有一行
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
<template>
|
||||
<div
|
||||
class="file-container select-none"
|
||||
:class="{ downloading: isDownloading, uploading: isUploading }"
|
||||
@click="handleFileClick">
|
||||
<!-- 文件信息 -->
|
||||
<div class="file-info select-none">
|
||||
<div class="file-name" :title="body?.fileName">
|
||||
{{ truncateFileName(body?.fileName || '未知文件') }}
|
||||
</div>
|
||||
<div class="file-size">
|
||||
{{ formatBytes(body?.size || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文件图标区域 -->
|
||||
<div class="file-icon-wrapper select-none cursor-pointer">
|
||||
<!-- 文件图标 -->
|
||||
<img
|
||||
:src="`/file/${getFileSuffix(body?.fileName || '')}.svg`"
|
||||
:alt="getFileSuffix(body?.fileName || '')"
|
||||
@error="handleIconError"
|
||||
class="file-icon-img" />
|
||||
|
||||
<!-- 蒙层和操作图标 -->
|
||||
<div v-if="isUploading || isDownloading || needsDownload" class="file-overlay">
|
||||
<!-- 上传中显示进度 -->
|
||||
<div v-if="isUploading" class="upload-progress">
|
||||
<div class="progress-circle">
|
||||
<svg class="progress-ring" width="24" height="24">
|
||||
<circle
|
||||
class="progress-ring-circle"
|
||||
stroke="rgba(19, 152, 127, 0.3)"
|
||||
stroke-width="2"
|
||||
fill="transparent"
|
||||
r="10"
|
||||
cx="12"
|
||||
cy="12" />
|
||||
<circle
|
||||
class="progress-ring-circle progress-ring-fill"
|
||||
stroke="#13987f"
|
||||
stroke-width="2"
|
||||
fill="transparent"
|
||||
r="10"
|
||||
cx="12"
|
||||
cy="12"
|
||||
:stroke-dasharray="`${2 * Math.PI * 10}`"
|
||||
:stroke-dashoffset="`${2 * Math.PI * 10 * (1 - (isUploading ? uploadProgress : downloadProgress) / 100)}`" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="progress-text">{{ isUploading ? uploadProgress : downloadProgress }}%</div>
|
||||
</div>
|
||||
<!-- 下载中显示进度 -->
|
||||
<div v-else-if="isDownloading" class="download-progress">
|
||||
<div v-if="downloadProgress > 0" class="progress-circle">
|
||||
<svg class="progress-ring" width="24" height="24">
|
||||
<circle
|
||||
class="progress-ring-circle"
|
||||
stroke="rgba(255, 255, 255, 0.3)"
|
||||
stroke-width="2"
|
||||
fill="transparent"
|
||||
r="10"
|
||||
cx="12"
|
||||
cy="12" />
|
||||
<circle
|
||||
class="progress-ring-circle progress-ring-fill"
|
||||
stroke="#fff"
|
||||
stroke-width="2"
|
||||
fill="transparent"
|
||||
r="10"
|
||||
cx="12"
|
||||
cy="12"
|
||||
:stroke-dasharray="`${2 * Math.PI * 10}`"
|
||||
:stroke-dashoffset="`${2 * Math.PI * 10 * (1 - downloadProgress / 100)}`" />
|
||||
</svg>
|
||||
</div>
|
||||
<svg v-else class="loading-icon">
|
||||
<use href="#loading"></use>
|
||||
</svg>
|
||||
<!-- 下载进度 -->
|
||||
<!-- <div v-if="downloadProgress > 0" class="progress-text">{{ downloadProgress }}%</div> -->
|
||||
</div>
|
||||
<!-- 需要下载显示下载图标 -->
|
||||
<div v-else-if="needsDownload" class="download-icon">
|
||||
<svg class="download-btn-icon">
|
||||
<use href="#arrow-down"></use>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FileBody } from '@/services/types'
|
||||
import { MessageStatusEnum } from '@/enums'
|
||||
import { formatBytes, getFileSuffix } from '@/utils/Formatting'
|
||||
import { useDownload } from '@/hooks/useDownload'
|
||||
import { useFileDownloadStore } from '@/stores/fileDownload'
|
||||
import { openPath, revealItemInDir } from '@tauri-apps/plugin-opener'
|
||||
|
||||
const { isDownloading: legacyIsDownloading } = useDownload()
|
||||
const fileDownloadStore = useFileDownloadStore()
|
||||
|
||||
const props = defineProps<{
|
||||
body: FileBody
|
||||
messageStatus?: MessageStatusEnum
|
||||
uploadProgress?: number
|
||||
}>()
|
||||
|
||||
// 上传状态
|
||||
const isUploading = computed(() => props.messageStatus === MessageStatusEnum.SENDING)
|
||||
const uploadProgress = computed(() => props.uploadProgress || 0)
|
||||
|
||||
// 文件下载状态
|
||||
const fileStatus = computed(() => {
|
||||
if (!props.body?.url) return null
|
||||
return fileDownloadStore.getFileStatus(props.body.url)
|
||||
})
|
||||
|
||||
// 是否正在下载
|
||||
const isDownloading = computed(() => {
|
||||
return fileStatus.value?.status === 'downloading' || legacyIsDownloading.value
|
||||
})
|
||||
|
||||
// 下载进度
|
||||
const downloadProgress = computed(() => {
|
||||
return fileStatus.value?.progress || 0
|
||||
})
|
||||
|
||||
// 是否需要下载(文件未下载到本地且不是上传/下载状态)
|
||||
const needsDownload = computed(() => {
|
||||
if (isUploading.value || isDownloading.value) return false
|
||||
if (!props.body?.url) return false
|
||||
|
||||
// 如果是本地文件路径,不需要下载
|
||||
if (props.body.url.startsWith('file://') || props.body.url.startsWith('/')) return false
|
||||
|
||||
// 检查文件是否已下载
|
||||
const status = fileStatus.value
|
||||
return !status?.isDownloaded
|
||||
})
|
||||
|
||||
// 监听 props 变化,重新检查文件状态
|
||||
watch(
|
||||
() => [props.body?.url, props.body?.fileName],
|
||||
async ([newUrl, newFileName]) => {
|
||||
if (newUrl && newFileName) {
|
||||
try {
|
||||
await fileDownloadStore.checkFileExists(newUrl, newFileName)
|
||||
} catch (error) {
|
||||
console.error('检查文件状态失败:', error)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
// 截断文件名,保留后缀
|
||||
const truncateFileName = (fileName: string): string => {
|
||||
if (!fileName) return '未知文件'
|
||||
|
||||
const maxWidth = 170 // 最大宽度像素
|
||||
const averageCharWidth = 9 // 平均字符宽度(基于14px Arial字体)
|
||||
const maxChars = Math.floor(maxWidth / averageCharWidth)
|
||||
|
||||
if (fileName.length <= maxChars) {
|
||||
return fileName
|
||||
}
|
||||
|
||||
// 获取文件扩展名
|
||||
const lastDotIndex = fileName.lastIndexOf('.')
|
||||
if (lastDotIndex === -1) {
|
||||
// 没有扩展名,直接截断
|
||||
return fileName.substring(0, maxChars - 3) + '...'
|
||||
}
|
||||
|
||||
const name = fileName.substring(0, lastDotIndex)
|
||||
const extension = fileName.substring(lastDotIndex)
|
||||
|
||||
// 计算可用于文件名的字符数(保留扩展名和省略号的空间)
|
||||
const availableChars = maxChars - extension.length - 3 // 3 是省略号的长度
|
||||
|
||||
if (availableChars <= 0) {
|
||||
// 如果扩展名太长,只显示扩展名
|
||||
return '...' + extension
|
||||
}
|
||||
|
||||
return name.substring(0, availableChars) + '...' + extension
|
||||
}
|
||||
|
||||
// 处理图标加载错误
|
||||
const handleIconError = (event: Event) => {
|
||||
const target = event.target as HTMLImageElement
|
||||
target.src = '/file/other.svg'
|
||||
}
|
||||
|
||||
// 处理文件点击
|
||||
const handleFileClick = async () => {
|
||||
if (!props.body?.url || !props.body?.fileName || isUploading.value) return
|
||||
|
||||
try {
|
||||
// 检查文件是否已下载
|
||||
const status = fileStatus.value
|
||||
|
||||
if (status?.isDownloaded && status.absolutePath) {
|
||||
// 文件已下载,尝试打开本地文件
|
||||
try {
|
||||
await openPath(status.absolutePath)
|
||||
} catch (openError) {
|
||||
await revealItemInDir(status.absolutePath)
|
||||
}
|
||||
} else if (needsDownload.value) {
|
||||
// 需要下载文件
|
||||
await downloadAndOpenFile()
|
||||
} else {
|
||||
// 本地文件路径,尝试打开
|
||||
try {
|
||||
await openPath(props.body.url)
|
||||
} catch (openError) {
|
||||
console.warn('无法直接打开文件,尝试在文件管理器中显示:', openError)
|
||||
await revealItemInDir(props.body.url)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('打开文件失败:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : '未知错误'
|
||||
if (errorMessage.includes('Not allowed to open path') || errorMessage.includes('revealItemInDir')) {
|
||||
console.error('无法打开或显示文件。请手动在文件管理器中找到并打开文件。')
|
||||
} else {
|
||||
console.error(`打开文件失败: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 下载并打开文件
|
||||
const downloadAndOpenFile = async () => {
|
||||
if (!props.body?.url || !props.body?.fileName) return
|
||||
|
||||
try {
|
||||
const fileName = props.body.fileName
|
||||
const absolutePath = await fileDownloadStore.downloadFile(props.body.url, fileName)
|
||||
|
||||
if (absolutePath) {
|
||||
// 下载成功后尝试打开文件
|
||||
try {
|
||||
await openPath(absolutePath)
|
||||
} catch (openError) {
|
||||
console.warn('无法直接打开文件,尝试在文件管理器中显示:', openError)
|
||||
await revealItemInDir(absolutePath)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('下载文件失败:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : '未知错误'
|
||||
if (errorMessage.includes('Not allowed to open path') || errorMessage.includes('revealItemInDir')) {
|
||||
window.$message?.error('文件下载成功,但无法打开或显示文件。请手动在文件管理器中查找下载的文件。')
|
||||
} else {
|
||||
window.$message?.error(`下载文件失败: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时检查文件状态
|
||||
onMounted(async () => {
|
||||
if (props.body?.url && props.body?.fileName) {
|
||||
try {
|
||||
// 检查文件是否已存在于本地
|
||||
await fileDownloadStore.checkFileExists(props.body.url, props.body.fileName)
|
||||
} catch (error) {
|
||||
console.error('检查文件状态失败:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.file-container {
|
||||
@apply custom-shadow px-14px py-8px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 225px;
|
||||
height: 85px;
|
||||
border-radius: 8px;
|
||||
background: #fdfdfd;
|
||||
cursor: default !important;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&.downloading {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&.uploading {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.file-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-family: Arial, sans-serif;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 8px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-family: Arial, sans-serif;
|
||||
font-weight: normal;
|
||||
font-size: 12px;
|
||||
color: #909090;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.file-icon-wrapper {
|
||||
position: absolute;
|
||||
right: 15px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.file-icon-img {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.file-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.upload-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.download-progress,
|
||||
.download-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.progress-circle {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.progress-ring {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.progress-ring-circle {
|
||||
transition: stroke-dashoffset 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 8px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.loading-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #fff;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.download-btn-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -17,6 +17,7 @@ import Announcement from './Announcement.vue'
|
||||
import type { Component } from 'vue'
|
||||
import Video from './Video.vue'
|
||||
import Voice from './Voice.vue'
|
||||
import File from './File.vue'
|
||||
|
||||
const componentMap: Partial<Record<MsgEnum, Component>> = {
|
||||
[MsgEnum.TEXT]: Text,
|
||||
@@ -24,6 +25,7 @@ const componentMap: Partial<Record<MsgEnum, Component>> = {
|
||||
[MsgEnum.EMOJI]: Emoji,
|
||||
[MsgEnum.VIDEO]: Video,
|
||||
[MsgEnum.VOICE]: Voice,
|
||||
[MsgEnum.FILE]: File,
|
||||
[MsgEnum.NOTICE]: Announcement
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -155,7 +155,9 @@ export enum StoresEnum {
|
||||
/** 配置 */
|
||||
CONFIG = 'config',
|
||||
/** 视频查看器数据 */
|
||||
VIDEOVIEWER = 'videoViewer'
|
||||
VIDEOVIEWER = 'videoViewer',
|
||||
/** 文件下载管理 */
|
||||
FILE_DOWNLOAD = 'fileDownload'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,12 +23,14 @@ import { useGroupStore } from '@/stores/group'
|
||||
import { useWindow } from './useWindow'
|
||||
import { useEmojiStore } from '@/stores/emoji'
|
||||
import { useVideoViewer } from '@/hooks/useVideoViewer'
|
||||
import { useFileDownloadStore } from '@/stores/fileDownload'
|
||||
import { extractFileName, removeTag } from '@/utils/Formatting'
|
||||
|
||||
export const useChatMain = () => {
|
||||
const { openMsgSession, userUid } = useCommon()
|
||||
const { createWebviewWindow } = useWindow()
|
||||
const { getLocalVideoPath, checkVideoDownloaded } = useVideoViewer()
|
||||
const fileDownloadStore = useFileDownloadStore()
|
||||
const settingStore = useSettingStore()
|
||||
const { chat } = storeToRefs(settingStore)
|
||||
const globalStore = useGlobalStore()
|
||||
@@ -265,18 +267,25 @@ export const useChatMain = () => {
|
||||
click: async (item: any) => {
|
||||
try {
|
||||
const fileUrl = item.message.body.url
|
||||
const filename = extractFileName(fileUrl)
|
||||
const savePath = await save({
|
||||
defaultPath: filename
|
||||
})
|
||||
if (savePath) {
|
||||
await downloadFile(fileUrl, savePath)
|
||||
// 下载完成后在文件管理器中显示
|
||||
await revealItemInDir(savePath)
|
||||
const fileName = item.message.body.fileName || extractFileName(fileUrl)
|
||||
|
||||
// 检查文件是否已下载
|
||||
const fileStatus = fileDownloadStore.getFileStatus(fileUrl)
|
||||
|
||||
if (fileStatus.isDownloaded && fileStatus.absolutePath) {
|
||||
// 文件已下载,直接显示
|
||||
await revealItemInDir(fileStatus.absolutePath)
|
||||
} else {
|
||||
// 文件未下载,先下载再显示
|
||||
window.$message.info('正在下载文件...')
|
||||
const absolutePath = await fileDownloadStore.downloadFile(fileUrl, fileName)
|
||||
|
||||
if (absolutePath) {
|
||||
await revealItemInDir(absolutePath)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('显示文件失败:', error)
|
||||
window.$message.error('显示文件失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
-19
@@ -1,6 +1,5 @@
|
||||
import { LimitEnum, MittEnum, MsgEnum, RoomTypeEnum } from '@/enums'
|
||||
import { Ref } from 'vue'
|
||||
import { createFileOrVideoDom } from '@/utils/CreateDom.ts'
|
||||
import GraphemeSplitter from 'grapheme-splitter'
|
||||
import router from '@/router'
|
||||
import apis from '@/services/apis.ts'
|
||||
@@ -708,6 +707,9 @@ export const useCommon = () => {
|
||||
return
|
||||
}
|
||||
|
||||
//缓存文件
|
||||
const cachePath = await saveCacheFile(file, 'img')
|
||||
|
||||
// 原有的File对象处理逻辑
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e: any) => {
|
||||
@@ -716,6 +718,9 @@ export const useCommon = () => {
|
||||
img.style.maxHeight = '88px'
|
||||
img.style.maxWidth = '140px'
|
||||
img.style.marginRight = '6px'
|
||||
// 设置ID,使用缓存路径作为ID,这样parseInnerText可以找到它
|
||||
img.id = 'temp-image'
|
||||
img.setAttribute('data-path', cachePath)
|
||||
|
||||
// 获取MsgInput组件暴露的lastEditRange
|
||||
const lastEditRange = (dom as any).getLastEditRange?.()
|
||||
@@ -744,8 +749,6 @@ export const useCommon = () => {
|
||||
|
||||
triggerInputEvent(dom)
|
||||
}
|
||||
//缓存文件
|
||||
await saveCacheFile(file, 'img')
|
||||
// 读取文件
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
@@ -756,20 +759,12 @@ export const useCommon = () => {
|
||||
* @param type 类型
|
||||
* @param dom 输入框dom
|
||||
*/
|
||||
const FileOrVideoPaste = async (file: File, type: MsgEnum, dom: HTMLElement) => {
|
||||
const FileOrVideoPaste = async (file: File) => {
|
||||
const reader = new FileReader()
|
||||
if (file.size > 1024 * 1024 * 50) {
|
||||
window.$message.warning('文件大小不能超过50M,请重新选择')
|
||||
return
|
||||
}
|
||||
// 使用函数
|
||||
createFileOrVideoDom(file).then((imgTag) => {
|
||||
// 将生成的img/video标签插入到页面中
|
||||
insertNode(type, imgTag, dom)
|
||||
|
||||
// insertNode已经处理了光标位置,直接触发输入事件
|
||||
triggerInputEvent(dom)
|
||||
})
|
||||
await saveCacheFile(file, 'video')
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
@@ -779,14 +774,9 @@ export const useCommon = () => {
|
||||
* @param files 文件列表
|
||||
* @param dom 输入框dom
|
||||
*/
|
||||
const handleConfirmFiles = async (files: File[], dom: HTMLElement) => {
|
||||
const handleConfirmFiles = async (files: File[]) => {
|
||||
for (const file of files) {
|
||||
const fileType = file.type as string
|
||||
if (fileType.startsWith('video/')) {
|
||||
await FileOrVideoPaste(file, MsgEnum.VIDEO, dom)
|
||||
} else {
|
||||
await FileOrVideoPaste(file, MsgEnum.FILE, dom)
|
||||
}
|
||||
await FileOrVideoPaste(file)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+277
-171
@@ -17,6 +17,8 @@ import { messageStrategyMap } from '@/strategy/MessageStrategy.ts'
|
||||
import { useTrigger } from './useTrigger'
|
||||
import type { AIModel } from '@/services/types.ts'
|
||||
import { UploadProviderEnum, useUpload } from './useUpload.ts'
|
||||
import { getReplyContent } from '@/utils/MessageReply.ts'
|
||||
import { fixFileMimeType, getMessageTypeByFile } from '@/utils/FileType.ts'
|
||||
/**
|
||||
* 光标管理器
|
||||
*/
|
||||
@@ -300,12 +302,20 @@ export const useMsgInput = (messageInputDom: Ref) => {
|
||||
if (html.includes('data-type="video"')) {
|
||||
return html
|
||||
}
|
||||
|
||||
const tmp = document.createElement('div')
|
||||
tmp.innerHTML = html
|
||||
const replyDiv = tmp.querySelector('#replyDiv')
|
||||
if (replyDiv) {
|
||||
replyDiv.remove()
|
||||
}
|
||||
|
||||
// 检查是否包含粘贴的图片(有temp-image id的图片元素)
|
||||
const pastedImage = tmp.querySelector('#temp-image')
|
||||
if (pastedImage) {
|
||||
return 'image' // 返回非空字符串,表示有内容
|
||||
}
|
||||
|
||||
return tmp.textContent?.trim() || tmp.innerText?.trim() || ''
|
||||
} catch (error) {
|
||||
console.error('Error in stripHtml:', error)
|
||||
@@ -749,19 +759,7 @@ export const useMsgInput = (messageInputDom: Ref) => {
|
||||
reply.value = { avatar: '', imgCount: 0, accountName: '', content: '', key: 0 }
|
||||
|
||||
// 步骤3: 处理回复内容
|
||||
// 回复前把包含 的字符替换成空格
|
||||
let content =
|
||||
event.message.body.content ||
|
||||
(event.message.type === MsgEnum.VIDEO
|
||||
? event.message.body.thumbUrl || event.message.body.url
|
||||
: event.message.body.url)
|
||||
if (content && typeof content === 'string') {
|
||||
content = content.replace(/ /g, ' ')
|
||||
} else if (Array.isArray(content)) {
|
||||
content = content.map((item: string) => {
|
||||
return typeof item === 'string' ? item.replace(/ /g, ' ') : item
|
||||
})
|
||||
}
|
||||
const content = getReplyContent(event.message)
|
||||
|
||||
// 步骤4: 设置新的回复内容
|
||||
reply.value = {
|
||||
@@ -807,187 +805,295 @@ export const useMsgInput = (messageInputDom: Ref) => {
|
||||
*/
|
||||
const sendFilesDirect = async (files: File[]) => {
|
||||
for (const file of files) {
|
||||
const videoFileName = file.name.toLowerCase()
|
||||
// 判断文件类型和修复MIME类型
|
||||
const processedFile = fixFileMimeType(file)
|
||||
let msgType = getMessageTypeByFile(processedFile)
|
||||
|
||||
// 修复 MIME 类型问题
|
||||
let processedFile = file
|
||||
if (!file.type || file.type === '') {
|
||||
const ext = videoFileName.split('.').pop()?.toLowerCase()
|
||||
let mimeType = 'video/mp4'
|
||||
|
||||
switch (ext) {
|
||||
case 'mp4':
|
||||
mimeType = 'video/mp4'
|
||||
break
|
||||
case 'mov':
|
||||
mimeType = 'video/quicktime'
|
||||
break
|
||||
case 'avi':
|
||||
mimeType = 'video/x-msvideo'
|
||||
break
|
||||
case 'wmv':
|
||||
mimeType = 'video/x-ms-wmv'
|
||||
break
|
||||
}
|
||||
|
||||
processedFile = new File([file], file.name, { type: mimeType })
|
||||
// 对音频文件进行特殊处理:通过文件选择的方式发送,作为文件类型处理
|
||||
if (msgType === MsgEnum.VOICE) {
|
||||
msgType = MsgEnum.FILE
|
||||
}
|
||||
|
||||
// 生成唯一消息ID,避免重复
|
||||
const tempMsgId = `${Date.now()}_${Math.random().toString(36).substring(2, 11)}`
|
||||
const msgType = MsgEnum.VIDEO
|
||||
const messageStrategy = messageStrategyMap[msgType]
|
||||
let progressUnsubscribe: (() => void) | null = null
|
||||
|
||||
try {
|
||||
// 立即创建并显示消息
|
||||
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
|
||||
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
|
||||
|
||||
chatStore.pushMsg(tempMsg)
|
||||
useMitt.emit(MittEnum.MESSAGE_ANIMATION, tempMsg)
|
||||
chatStore.pushMsg(tempMsg)
|
||||
useMitt.emit(MittEnum.MESSAGE_ANIMATION, tempMsg)
|
||||
|
||||
// 异步处理上传
|
||||
const videoPath = await saveCacheFile(processedFile, 'video/')
|
||||
// 异步处理上传
|
||||
const videoPath = await saveCacheFile(processedFile, 'video/')
|
||||
|
||||
// 直接使用 VideoMessageStrategy 生成缩略图,避免重复处理
|
||||
const videoStrategy = messageStrategy as any
|
||||
const thumbnailFile = await videoStrategy.getVideoThumbnail(processedFile)
|
||||
// 直接使用 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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 添加监听器
|
||||
onChange(handleProgress)
|
||||
|
||||
// 创建取消函数
|
||||
progressUnsubscribe = () => {
|
||||
isProgressActive = false
|
||||
console.log(`🗑️ 清理进度监听器 (消息ID: ${tempMsgId})`)
|
||||
}
|
||||
|
||||
let videoUploadResponse: any = null
|
||||
try {
|
||||
// 上传视频
|
||||
videoUploadResponse = await messageStrategy.doUpload(videoPath, videoUploadResult.uploadUrl, {
|
||||
provider: UploadProviderEnum.QINIU,
|
||||
...qiniuConfig
|
||||
// 生成本地缩略图预览URL,立即更新消息显示
|
||||
const localThumbUrl = URL.createObjectURL(thumbnailFile)
|
||||
chatStore.updateMsg({
|
||||
msgId: tempMsgId,
|
||||
status: MessageStatusEnum.SENDING,
|
||||
body: {
|
||||
...tempMsg.message.body,
|
||||
thumbUrl: localThumbUrl,
|
||||
thumbSize: thumbnailFile.size
|
||||
}
|
||||
})
|
||||
|
||||
// 清理进度监听器
|
||||
if (progressUnsubscribe) {
|
||||
progressUnsubscribe()
|
||||
progressUnsubscribe = null
|
||||
// 获取一次七牛云配置,共享使用
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (uploadError) {
|
||||
// 清理进度监听器
|
||||
if (progressUnsubscribe) {
|
||||
progressUnsubscribe()
|
||||
progressUnsubscribe = null
|
||||
|
||||
// 添加监听器
|
||||
onChange(handleProgress)
|
||||
|
||||
// 创建取消函数
|
||||
progressUnsubscribe = () => {
|
||||
isProgressActive = false
|
||||
console.log(`🗑️ 清理进度监听器 (消息ID: ${tempMsgId})`)
|
||||
}
|
||||
throw uploadError
|
||||
|
||||
let videoUploadResponse: any = null
|
||||
try {
|
||||
// 上传视频
|
||||
videoUploadResponse = await messageStrategy.doUpload(videoPath, videoUploadResult.uploadUrl, {
|
||||
provider: UploadProviderEnum.QINIU,
|
||||
...qiniuConfig
|
||||
})
|
||||
|
||||
// 清理进度监听器
|
||||
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}`
|
||||
|
||||
// 发送消息到服务器保存
|
||||
const serverResponse = await apis.sendMsg({
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
// 使用服务器返回的数据更新消息状态为SUCCESS,清除进度信息
|
||||
chatStore.updateMsg({
|
||||
msgId: tempMsgId,
|
||||
status: MessageStatusEnum.SUCCESS,
|
||||
newMsgId: serverResponse.message.id, // 使用服务器返回的消息ID
|
||||
body: serverResponse.message.body, // 使用服务器返回的消息体
|
||||
uploadProgress: undefined // 清除进度信息
|
||||
})
|
||||
|
||||
// 清理本地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
|
||||
|
||||
// 添加到消息列表
|
||||
chatStore.pushMsg(tempMsg)
|
||||
useMitt.emit(MittEnum.MESSAGE_ANIMATION, tempMsg)
|
||||
|
||||
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)
|
||||
|
||||
// 发送消息到服务器
|
||||
const serverResponse = await apis.sendMsg({
|
||||
roomId: globalStore.currentSession.roomId,
|
||||
msgType: MsgEnum.IMAGE,
|
||||
body: messageBody
|
||||
})
|
||||
|
||||
// 更新消息状态为成功,并使用服务器返回的消息体
|
||||
chatStore.updateMsg({
|
||||
msgId: tempMsgId,
|
||||
status: MessageStatusEnum.SUCCESS,
|
||||
newMsgId: serverResponse.message.id,
|
||||
body: serverResponse.message.body
|
||||
})
|
||||
|
||||
// 更新会话最后活动时间
|
||||
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
|
||||
|
||||
// 添加到消息列表
|
||||
chatStore.pushMsg(tempMsg)
|
||||
useMitt.emit(MittEnum.MESSAGE_ANIMATION, tempMsg)
|
||||
|
||||
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)
|
||||
|
||||
// 发送消息到服务器
|
||||
const serverResponse = await apis.sendMsg({
|
||||
roomId: globalStore.currentSession.roomId,
|
||||
msgType: MsgEnum.FILE,
|
||||
body: messageBody
|
||||
})
|
||||
|
||||
// 更新消息状态为成功,并使用服务器返回的消息体
|
||||
chatStore.updateMsg({
|
||||
msgId: tempMsgId,
|
||||
status: MessageStatusEnum.SUCCESS,
|
||||
newMsgId: serverResponse.message.id,
|
||||
body: serverResponse.message.body
|
||||
})
|
||||
|
||||
// 更新会话最后活动时间
|
||||
chatStore.updateSessionLastActiveTime(globalStore.currentSession.roomId)
|
||||
|
||||
console.log('📎 文件消息发送成功:', serverResponse.message.id)
|
||||
}
|
||||
|
||||
// 直接使用七牛云上传缩略图,避免通过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}`
|
||||
|
||||
// 发送消息到服务器保存
|
||||
const serverResponse = await apis.sendMsg({
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
// 使用服务器返回的数据更新消息状态为SUCCESS,清除进度信息
|
||||
chatStore.updateMsg({
|
||||
msgId: tempMsgId,
|
||||
status: MessageStatusEnum.SUCCESS,
|
||||
newMsgId: serverResponse.message.id, // 使用服务器返回的消息ID
|
||||
body: serverResponse.message.body, // 使用服务器返回的消息体
|
||||
uploadProgress: undefined // 清除进度信息
|
||||
})
|
||||
|
||||
// 清理本地URL
|
||||
URL.revokeObjectURL(tempMsg.message.body.url)
|
||||
URL.revokeObjectURL(localThumbUrl)
|
||||
|
||||
// 清空输入框内容,避免重复发送
|
||||
if (messageInputDom.value) {
|
||||
messageInputDom.value.innerHTML = ''
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('视频发送失败:', error)
|
||||
console.error(`${msgType === MsgEnum.VIDEO ? '视频' : '文件'}发送失败:`, error)
|
||||
|
||||
// 确保清理进度监听器
|
||||
if (progressUnsubscribe) {
|
||||
@@ -1000,7 +1106,7 @@ export const useMsgInput = (messageInputDom: Ref) => {
|
||||
status: MessageStatusEnum.FAILED,
|
||||
uploadProgress: undefined // 清除进度信息
|
||||
})
|
||||
window.$message.error('视频发送失败')
|
||||
window.$message.error(`${msgType === MsgEnum.VIDEO ? '视频' : '文件'}发送失败`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { StoresEnum } from '@/enums'
|
||||
import { getUserVideosDir } from '@/utils/PathUtil'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useGlobalStore } from '@/stores/global'
|
||||
import { join, resourceDir } from '@tauri-apps/api/path'
|
||||
import { writeFile, exists } from '@tauri-apps/plugin-fs'
|
||||
import { BaseDirectory } from '@tauri-apps/plugin-fs'
|
||||
|
||||
export interface FileDownloadStatus {
|
||||
/** 文件是否已下载 */
|
||||
isDownloaded: boolean
|
||||
/** 本地文件相对路径 (相对于 Resource 目录) */
|
||||
localPath?: string
|
||||
/** 本地文件绝对路径 */
|
||||
absolutePath?: string
|
||||
/** 原生路径格式 (用于文件操作) */
|
||||
nativePath?: string
|
||||
/** 显示路径格式 (规范化后) */
|
||||
displayPath?: string
|
||||
/** 下载状态 */
|
||||
status: 'pending' | 'downloading' | 'completed' | 'failed'
|
||||
/** 下载进度 */
|
||||
progress?: number
|
||||
/** 错误信息 */
|
||||
error?: string
|
||||
}
|
||||
|
||||
export const useFileDownloadStore = defineStore(
|
||||
StoresEnum.FILE_DOWNLOAD,
|
||||
() => {
|
||||
const userStore = useUserStore()
|
||||
const globalStore = useGlobalStore()
|
||||
|
||||
// 存储文件下载状态的Map,key为文件URL,value为下载状态
|
||||
const downloadStatusMap = ref<Map<string, FileDownloadStatus>>(new Map())
|
||||
|
||||
/**
|
||||
* 获取文件下载状态
|
||||
* @param fileUrl 文件URL
|
||||
*/
|
||||
const getFileStatus = (fileUrl: string): FileDownloadStatus => {
|
||||
return (
|
||||
downloadStatusMap.value.get(fileUrl) || {
|
||||
isDownloaded: false,
|
||||
status: 'pending'
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新文件下载状态
|
||||
* @param fileUrl 文件URL
|
||||
* @param status 状态更新
|
||||
*/
|
||||
const updateFileStatus = (fileUrl: string, status: Partial<FileDownloadStatus>) => {
|
||||
const currentStatus = getFileStatus(fileUrl)
|
||||
const newStatus = { ...currentStatus, ...status }
|
||||
downloadStatusMap.value.set(fileUrl, newStatus)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文件是否已下载
|
||||
* @param fileUrl 文件URL
|
||||
* @param fileName 文件名
|
||||
*/
|
||||
const checkFileExists = async (fileUrl: string, fileName: string): Promise<boolean> => {
|
||||
try {
|
||||
const userUid = userStore.userInfo.uid
|
||||
const roomId = globalStore.currentSession.roomId
|
||||
|
||||
if (!userUid || !roomId) return false
|
||||
|
||||
const downloadsDir = await getUserVideosDir(userUid.toString(), roomId.toString())
|
||||
const filePath = await join(downloadsDir, fileName)
|
||||
|
||||
const fileExists = await exists(filePath, { baseDir: BaseDirectory.Resource })
|
||||
|
||||
if (fileExists) {
|
||||
// 文件存在,构建绝对路径并更新状态
|
||||
const resourceDirPath = await resourceDir()
|
||||
const absolutePath = await join(resourceDirPath, filePath)
|
||||
|
||||
// 保持原生路径格式用于文件操作,规范化路径用于显示
|
||||
const normalizedPath = absolutePath.replace(/\\/g, '/')
|
||||
|
||||
updateFileStatus(fileUrl, {
|
||||
isDownloaded: true,
|
||||
localPath: filePath,
|
||||
absolutePath: absolutePath, // 使用原生路径格式
|
||||
nativePath: absolutePath, // 保存原生路径
|
||||
displayPath: normalizedPath, // 保存显示路径
|
||||
status: 'completed'
|
||||
})
|
||||
}
|
||||
|
||||
return fileExists
|
||||
} catch (error) {
|
||||
console.error('检查文件是否存在失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
* @param fileUrl 文件URL
|
||||
* @param fileName 文件名
|
||||
*/
|
||||
const downloadFile = async (fileUrl: string, fileName: string): Promise<string | null> => {
|
||||
try {
|
||||
const userUid = userStore.userInfo.uid
|
||||
const roomId = globalStore.currentSession.roomId
|
||||
|
||||
if (!userUid || !roomId) {
|
||||
throw new Error('用户或房间信息不完整')
|
||||
}
|
||||
|
||||
// 检查文件是否已存在
|
||||
const isExists = await checkFileExists(fileUrl, fileName)
|
||||
if (isExists) {
|
||||
const existingStatus = getFileStatus(fileUrl)
|
||||
return existingStatus.localPath || null
|
||||
}
|
||||
|
||||
// 更新状态为下载中
|
||||
updateFileStatus(fileUrl, {
|
||||
status: 'downloading',
|
||||
progress: 0
|
||||
})
|
||||
|
||||
// 获取下载目录
|
||||
const downloadsDir = await getUserVideosDir(userUid.toString(), roomId.toString())
|
||||
const filePath = await join(downloadsDir, fileName)
|
||||
|
||||
// 下载文件
|
||||
const response = await fetch(fileUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`下载失败: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const contentLength = response.headers.get('content-length')
|
||||
const total = contentLength ? parseInt(contentLength, 10) : 0
|
||||
let downloaded = 0
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) {
|
||||
throw new Error('无法读取响应流')
|
||||
}
|
||||
|
||||
const chunks: Uint8Array[] = []
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
chunks.push(value)
|
||||
downloaded += value.length
|
||||
|
||||
// 更新下载进度
|
||||
if (total > 0) {
|
||||
const progress = Math.round((downloaded / total) * 100)
|
||||
updateFileStatus(fileUrl, {
|
||||
status: 'downloading',
|
||||
progress
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 合并所有数据块
|
||||
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0)
|
||||
const fileData = new Uint8Array(totalLength)
|
||||
let offset = 0
|
||||
|
||||
for (const chunk of chunks) {
|
||||
fileData.set(chunk, offset)
|
||||
offset += chunk.length
|
||||
}
|
||||
|
||||
// 写入文件
|
||||
await writeFile(filePath, fileData, { baseDir: BaseDirectory.Resource })
|
||||
|
||||
// 构建绝对路径
|
||||
const resourceDirPath = await resourceDir()
|
||||
const absolutePath = await join(resourceDirPath, filePath)
|
||||
|
||||
// 保持原生路径格式用于文件操作,规范化路径用于显示
|
||||
const normalizedPath = absolutePath.replace(/\\/g, '/')
|
||||
|
||||
// 更新状态为完成
|
||||
updateFileStatus(fileUrl, {
|
||||
isDownloaded: true,
|
||||
localPath: filePath,
|
||||
absolutePath: absolutePath, // 使用原生路径格式
|
||||
nativePath: absolutePath, // 保存原生路径
|
||||
displayPath: normalizedPath, // 保存显示路径
|
||||
status: 'completed',
|
||||
progress: 100
|
||||
})
|
||||
|
||||
console.log(`文件下载成功: ${normalizedPath}`)
|
||||
return absolutePath // 返回原生路径格式
|
||||
} catch (error) {
|
||||
console.error('文件下载失败:', error)
|
||||
|
||||
// 更新状态为失败
|
||||
updateFileStatus(fileUrl, {
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : '下载失败'
|
||||
})
|
||||
|
||||
window.$message?.error(`文件下载失败: ${error instanceof Error ? error.message : '未知错误'}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本地文件路径
|
||||
* @param fileUrl 文件URL
|
||||
* @param absolute 是否返回绝对路径,默认为 true
|
||||
*/
|
||||
const getLocalPath = (fileUrl: string, absolute: boolean = true): string | null => {
|
||||
const status = getFileStatus(fileUrl)
|
||||
if (!status.isDownloaded) return null
|
||||
|
||||
return absolute ? status.absolutePath || null : status.localPath || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理下载状态
|
||||
*/
|
||||
const clearDownloadStatus = () => {
|
||||
downloadStatusMap.value.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除特定文件的下载状态
|
||||
* @param fileUrl 文件URL
|
||||
*/
|
||||
const removeFileStatus = (fileUrl: string) => {
|
||||
downloadStatusMap.value.delete(fileUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量检查文件状态
|
||||
* @param fileInfos 文件信息数组
|
||||
*/
|
||||
const batchCheckFileStatus = async (fileInfos: Array<{ url: string; fileName: string }>) => {
|
||||
const promises = fileInfos.map(({ url, fileName }) => checkFileExists(url, fileName))
|
||||
|
||||
await Promise.all(promises)
|
||||
}
|
||||
|
||||
return {
|
||||
downloadStatusMap: readonly(downloadStatusMap),
|
||||
getFileStatus,
|
||||
updateFileStatus,
|
||||
checkFileExists,
|
||||
downloadFile,
|
||||
getLocalPath,
|
||||
clearDownloadStatus,
|
||||
removeFileStatus,
|
||||
batchCheckFileStatus
|
||||
}
|
||||
},
|
||||
{
|
||||
share: {
|
||||
enable: true,
|
||||
initialize: true
|
||||
}
|
||||
}
|
||||
)
|
||||
+213
-40
@@ -11,6 +11,7 @@ import { getImageDimensions } from '@/utils/ImageUtils'
|
||||
import { getMimeTypeFromExtension, removeTag } from '@/utils/Formatting'
|
||||
import { join, appCacheDir } from '@tauri-apps/api/path'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isVideoUrl, fixFileMimeType } from '@/utils/FileType'
|
||||
|
||||
interface MessageStrategy {
|
||||
getMsg: (msgInputValue: string, replyValue: any, fileList?: File[]) => any
|
||||
@@ -143,7 +144,7 @@ class TextMessageStrategyImpl extends AbstractMessageStrategy {
|
||||
|
||||
/** 处理图片消息 */
|
||||
class ImageMessageStrategyImpl extends AbstractMessageStrategy {
|
||||
// 最大上传文件大小 2MB
|
||||
// 最大上传图片大小 2MB
|
||||
private readonly MAX_UPLOAD_SIZE = 2 * 1024 * 1024
|
||||
// 支持的图片类型
|
||||
private readonly ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp']
|
||||
@@ -159,17 +160,20 @@ class ImageMessageStrategyImpl extends AbstractMessageStrategy {
|
||||
* @returns 验证后的图片文件
|
||||
*/
|
||||
private async validateImage(file: File): Promise<File> {
|
||||
// 先修复可能缺失或错误的MIME类型
|
||||
const fixedFile = fixFileMimeType(file)
|
||||
|
||||
// 检查文件类型
|
||||
if (!this.ALLOWED_TYPES.includes(file.type)) {
|
||||
if (!this.ALLOWED_TYPES.includes(fixedFile.type)) {
|
||||
throw new AppException('仅支持 JPEG、PNG、WebP 格式的图片')
|
||||
}
|
||||
|
||||
// 检查文件大小
|
||||
if (file.size > this.MAX_UPLOAD_SIZE) {
|
||||
if (fixedFile.size > this.MAX_UPLOAD_SIZE) {
|
||||
throw new AppException('图片大小不能超过2MB')
|
||||
}
|
||||
|
||||
return file
|
||||
return fixedFile
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,6 +238,40 @@ class ImageMessageStrategyImpl extends AbstractMessageStrategy {
|
||||
async getMsg(msgInputValue: string, replyValue: any, fileList?: File[]): Promise<any> {
|
||||
console.log('开始处理图片消息:', msgInputValue, replyValue, fileList?.length ? '有附件文件' : '无附件文件')
|
||||
|
||||
// 优先处理fileList中的文件
|
||||
if (fileList && fileList.length > 0) {
|
||||
const file = fileList[0]
|
||||
|
||||
// 验证图片
|
||||
await this.validateImage(file)
|
||||
|
||||
// 获取图片信息(宽度、高度)和预览URL
|
||||
const { width, height, previewUrl } = await this.getImageInfo(file)
|
||||
|
||||
// 将文件保存到缓存目录
|
||||
const tempPath = `temp-image-${Date.now()}-${file.name}`
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const uint8Array = new Uint8Array(arrayBuffer)
|
||||
await writeFile(tempPath, uint8Array, { baseDir: BaseDirectory.AppCache })
|
||||
|
||||
return {
|
||||
type: this.msgType,
|
||||
path: tempPath, // 用于上传
|
||||
url: previewUrl, // 用于预览显示
|
||||
imageInfo: {
|
||||
width, // 原始图片宽度
|
||||
height, // 原始图片高度
|
||||
size: file.size // 原始文件大小
|
||||
},
|
||||
reply: replyValue.content
|
||||
? {
|
||||
content: replyValue.content,
|
||||
key: replyValue.key
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否是图片URL
|
||||
if (this.isImageUrl(msgInputValue)) {
|
||||
try {
|
||||
@@ -265,8 +303,14 @@ class ImageMessageStrategyImpl extends AbstractMessageStrategy {
|
||||
}
|
||||
}
|
||||
|
||||
// 原有的本地图片处理逻辑
|
||||
const path = parseInnerText(msgInputValue, 'temp-image')
|
||||
// 原有的本地图片处理逻辑(从HTML解析)
|
||||
const doc = new DOMParser().parseFromString(msgInputValue, 'text/html')
|
||||
const imgElement = doc.getElementById('temp-image')
|
||||
if (!imgElement) {
|
||||
throw new AppException('文件不存在')
|
||||
}
|
||||
|
||||
const path = imgElement.getAttribute('data-path')
|
||||
if (!path) {
|
||||
throw new AppException('文件不存在')
|
||||
}
|
||||
@@ -406,15 +450,81 @@ class ImageMessageStrategyImpl extends AbstractMessageStrategy {
|
||||
* 处理文件消息
|
||||
*/
|
||||
class FileMessageStrategyImpl extends AbstractMessageStrategy {
|
||||
// 最大上传文件大小 100MB
|
||||
private readonly MAX_UPLOAD_SIZE = 100 * 1024 * 1024
|
||||
private uploadHook = useUpload()
|
||||
|
||||
constructor() {
|
||||
super(MsgEnum.FILE)
|
||||
}
|
||||
|
||||
getMsg(msgInputValue: string, replyValue: any, fileList?: File[]): any {
|
||||
fileList
|
||||
/**
|
||||
* 验证文件是否符合上传条件
|
||||
* @param file 文件对象
|
||||
* @returns 验证后的文件
|
||||
*/
|
||||
private async validateFile(file: File): Promise<File> {
|
||||
// 检查文件大小
|
||||
if (file.size > this.MAX_UPLOAD_SIZE) {
|
||||
throw new AppException('文件大小不能超过100MB')
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件路径读取文件信息
|
||||
* @param path 文件路径
|
||||
* @returns 文件信息
|
||||
*/
|
||||
private async getFileFromPath(path: string): Promise<File> {
|
||||
try {
|
||||
const normalizedPath = path.replace(/\\/g, '/')
|
||||
const fileData = await readFile(normalizedPath, { baseDir: BaseDirectory.AppCache })
|
||||
|
||||
const fileName = normalizedPath.split('/').pop() || 'unknown'
|
||||
const fileType = getMimeTypeFromExtension(fileName)
|
||||
|
||||
return new File([new Uint8Array(fileData)], fileName, { type: fileType })
|
||||
} catch (error) {
|
||||
console.error('读取文件失败:', error)
|
||||
throw new AppException('无法读取文件,请检查文件是否存在')
|
||||
}
|
||||
}
|
||||
|
||||
async getMsg(msgInputValue: string, replyValue: any, fileList?: File[]): Promise<any> {
|
||||
console.log('开始处理文件消息:', msgInputValue, replyValue, fileList?.length ? '有附件文件' : '无附件文件')
|
||||
|
||||
let file: File | null = null
|
||||
|
||||
// 优先使用fileList中的文件
|
||||
if (fileList && fileList.length > 0) {
|
||||
file = fileList[0]
|
||||
} else {
|
||||
// 尝试从msgInputValue解析文件路径
|
||||
const path = parseInnerText(msgInputValue, 'temp-file')
|
||||
if (!path) {
|
||||
throw new AppException('请选择要发送的文件')
|
||||
}
|
||||
file = await this.getFileFromPath(path)
|
||||
}
|
||||
|
||||
// 验证文件
|
||||
const validatedFile = await this.validateFile(file)
|
||||
|
||||
// 创建临时路径用于上传
|
||||
const tempPath = `temp-file-${Date.now()}-${validatedFile.name}`
|
||||
|
||||
// 将文件保存到临时位置
|
||||
const arrayBuffer = await validatedFile.arrayBuffer()
|
||||
const uint8Array = new Uint8Array(arrayBuffer)
|
||||
await writeFile(tempPath, uint8Array, { baseDir: BaseDirectory.AppCache })
|
||||
|
||||
return {
|
||||
type: this.msgType,
|
||||
content: msgInputValue,
|
||||
path: tempPath,
|
||||
fileName: validatedFile.name,
|
||||
size: validatedFile.size,
|
||||
mimeType: validatedFile.type,
|
||||
reply: replyValue.content
|
||||
? {
|
||||
content: replyValue.content,
|
||||
@@ -425,17 +535,73 @@ class FileMessageStrategyImpl extends AbstractMessageStrategy {
|
||||
}
|
||||
|
||||
buildMessageBody(msg: any, reply: any): any {
|
||||
msg
|
||||
reply
|
||||
throw new AppException('方法暂未实现')
|
||||
return {
|
||||
url: '', // 上传后会被设置
|
||||
path: msg.path,
|
||||
fileName: msg.fileName,
|
||||
size: msg.size,
|
||||
mimeType: msg.mimeType,
|
||||
replyMsgId: msg.reply?.key || undefined,
|
||||
reply: reply.value.content
|
||||
? {
|
||||
body: reply.value.content,
|
||||
id: reply.value.key,
|
||||
username: reply.value.accountName,
|
||||
type: msg.type
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
buildMessageType(messageId: string, messageBody: any, globalStore: any, userUid: Ref<any>): MessageType {
|
||||
messageId
|
||||
messageBody
|
||||
globalStore
|
||||
userUid
|
||||
throw new AppException('方法暂未实现')
|
||||
/**
|
||||
* 上传文件
|
||||
* @param path 文件路径
|
||||
* @param options 上传选项
|
||||
* @returns 上传结果
|
||||
*/
|
||||
async uploadFile(
|
||||
path: string,
|
||||
options?: { provider?: UploadProviderEnum }
|
||||
): Promise<{ uploadUrl: string; downloadUrl: string; config?: any }> {
|
||||
console.log('开始上传文件:', path)
|
||||
try {
|
||||
const uploadOptions: UploadOptions = {
|
||||
provider: options?.provider || UploadProviderEnum.QINIU,
|
||||
scene: UploadSceneEnum.CHAT
|
||||
}
|
||||
|
||||
const result = await this.uploadHook.getUploadAndDownloadUrl(path, uploadOptions)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('获取文件上传链接失败:', error)
|
||||
throw new AppException('获取文件上传链接失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行实际的文件上传
|
||||
* @param path 文件路径
|
||||
* @param uploadUrl 上传URL
|
||||
* @param options 上传选项
|
||||
* @returns 上传结果
|
||||
*/
|
||||
async doUpload(path: string, uploadUrl: string, options?: any): Promise<{ qiniuUrl?: string } | void> {
|
||||
console.log('执行文件上传:', path)
|
||||
try {
|
||||
// enableDeduplication启用文件去重
|
||||
const result = await this.uploadHook.doUpload(path, uploadUrl, { ...options, enableDeduplication: true })
|
||||
|
||||
// 如果是七牛云上传,返回qiniuUrl
|
||||
if (options?.provider === UploadProviderEnum.QINIU) {
|
||||
return { qiniuUrl: result as string }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('文件上传失败:', error)
|
||||
if (error instanceof AppException) {
|
||||
throw error
|
||||
}
|
||||
throw new AppException('文件上传失败,请重试')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -667,8 +833,33 @@ class VideoMessageStrategyImpl extends AbstractMessageStrategy {
|
||||
}
|
||||
|
||||
async getMsg(msgInputValue: string, replyValue: any, fileList?: File[]): Promise<any> {
|
||||
// 1. 优先处理远程视频URL的情况
|
||||
if (this.isVideoUrl(msgInputValue)) {
|
||||
// 1. 优先处理fileList中的文件
|
||||
if (fileList && fileList.length > 0) {
|
||||
const file = fileList[0]
|
||||
|
||||
// 验证视频文件
|
||||
const validatedFile = await this.validateVideo(file)
|
||||
const thumbnail = await this.getVideoThumbnail(validatedFile)
|
||||
|
||||
// 将文件保存到缓存目录
|
||||
const tempPath = `temp-video-${Date.now()}-${file.name}`
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const uint8Array = new Uint8Array(arrayBuffer)
|
||||
await writeFile(tempPath, uint8Array, { baseDir: BaseDirectory.AppCache })
|
||||
|
||||
return {
|
||||
type: this.msgType,
|
||||
path: tempPath,
|
||||
url: '', // 上传后会更新
|
||||
thumbnail: thumbnail || '',
|
||||
size: validatedFile.size,
|
||||
duration: 0, // 实际项目中可解析视频时长
|
||||
reply: replyValue.content ? { content: replyValue.content, key: replyValue.key } : undefined
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 处理远程视频URL的情况
|
||||
if (isVideoUrl(msgInputValue)) {
|
||||
return {
|
||||
type: this.msgType,
|
||||
url: msgInputValue,
|
||||
@@ -676,13 +867,7 @@ class VideoMessageStrategyImpl extends AbstractMessageStrategy {
|
||||
reply: replyValue.content ? { content: replyValue.content, key: replyValue.key } : undefined
|
||||
}
|
||||
}
|
||||
if (!fileList?.[0] && !msgInputValue) {
|
||||
throw new AppException('请提供有效的视频文件或URL')
|
||||
}
|
||||
const actualFile = await this.convertToVideoFile(msgInputValue)
|
||||
if (!actualFile) {
|
||||
throw new AppException('请选择视频文件或提供有效的视频URL')
|
||||
}
|
||||
|
||||
// 4. 验证视频文件
|
||||
const validatedFile = await this.validateVideo(actualFile)
|
||||
@@ -875,7 +1060,7 @@ class VideoMessageStrategyImpl extends AbstractMessageStrategy {
|
||||
options?: { provider?: UploadProviderEnum }
|
||||
): Promise<{ uploadUrl: string; downloadUrl: string; config?: any }> {
|
||||
// 远程视频直接返回URL
|
||||
if (this.isVideoUrl(path)) {
|
||||
if (isVideoUrl(path)) {
|
||||
return { uploadUrl: '', downloadUrl: path }
|
||||
}
|
||||
|
||||
@@ -890,7 +1075,7 @@ class VideoMessageStrategyImpl extends AbstractMessageStrategy {
|
||||
}
|
||||
}
|
||||
async doUpload(path: string, uploadUrl: string, options?: any): Promise<{ qiniuUrl?: string } | void> {
|
||||
if (this.isVideoUrl(path)) {
|
||||
if (isVideoUrl(path)) {
|
||||
throw new AppException('检查是否是有效的视频URL')
|
||||
}
|
||||
|
||||
@@ -908,18 +1093,6 @@ class VideoMessageStrategyImpl extends AbstractMessageStrategy {
|
||||
throw new AppException('文件上传失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否是有效的视频URL
|
||||
*/
|
||||
private isVideoUrl(url: string): boolean {
|
||||
try {
|
||||
new URL(url)
|
||||
return /\.(mp4|mov|avi|wmv)$/i.test(url)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class UnsupportedMessageStrategyImpl extends AbstractMessageStrategy {
|
||||
|
||||
Vendored
+1
@@ -21,6 +21,7 @@ declare module 'vue' {
|
||||
Details: typeof import('./../components/rightBox/Details.vue')['default']
|
||||
Emoji: typeof import('./../components/rightBox/renderMessage/Emoji.vue')['default']
|
||||
Emoticon: typeof import('./../components/rightBox/emoticon/index.vue')['default']
|
||||
File: typeof import('./../components/rightBox/renderMessage/File.vue')['default']
|
||||
FileUploadModal: typeof import('./../components/rightBox/FileUploadModal.vue')['default']
|
||||
FloatBlockList: typeof import('./../components/common/FloatBlockList.vue')['default']
|
||||
Image: typeof import('./../components/rightBox/renderMessage/Image.vue')['default']
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { MsgEnum } from '@/enums'
|
||||
|
||||
/**
|
||||
* 支持的视频扩展名(统一定义)
|
||||
*/
|
||||
export const SUPPORTED_VIDEO_EXTENSIONS = ['mp4', 'mov', 'avi', 'wmv', 'mkv', 'flv', 'webm', 'm4v'] as const
|
||||
|
||||
/**
|
||||
* 支持的音频扩展名
|
||||
*/
|
||||
export const SUPPORTED_AUDIO_EXTENSIONS = ['mp3', 'wav', 'm4a', 'aac', 'ogg', 'flac'] as const
|
||||
|
||||
/**
|
||||
* 支持的图片扩展名
|
||||
*/
|
||||
export const SUPPORTED_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'] as const
|
||||
|
||||
/**
|
||||
* 视频MIME类型映射
|
||||
*/
|
||||
export const VIDEO_MIME_TYPE_MAP: Record<string, string> = {
|
||||
mp4: 'video/mp4',
|
||||
mov: 'video/quicktime',
|
||||
avi: 'video/x-msvideo',
|
||||
wmv: 'video/x-ms-wmv',
|
||||
mkv: 'video/x-matroska',
|
||||
flv: 'video/x-flv',
|
||||
webm: 'video/webm',
|
||||
m4v: 'video/mp4'
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 音频MIME类型映射
|
||||
*/
|
||||
export const AUDIO_MIME_TYPE_MAP: Record<string, string> = {
|
||||
mp3: 'audio/mpeg',
|
||||
wav: 'audio/wav',
|
||||
m4a: 'audio/mp4',
|
||||
aac: 'audio/aac',
|
||||
ogg: 'audio/ogg',
|
||||
flac: 'audio/flac'
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 图片MIME类型映射
|
||||
*/
|
||||
export const IMAGE_MIME_TYPE_MAP: Record<string, string> = {
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
bmp: 'image/bmp',
|
||||
svg: 'image/svg+xml'
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 从文件名获取扩展名
|
||||
* @param fileName 文件名
|
||||
* @returns 小写的扩展名(不含点)
|
||||
*/
|
||||
export const getFileExtension = (fileName: string): string => {
|
||||
const parts = fileName.split('.')
|
||||
return parts.length > 1 ? parts.pop()!.toLowerCase() : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用文件类型检查函数
|
||||
* @param fileOrName File对象或文件名
|
||||
* @param supportedExtensions 支持的扩展名数组
|
||||
* @param mimeTypePrefix MIME类型前缀(如 'video/', 'audio/', 'image/')
|
||||
* @returns 是否为指定类型的文件
|
||||
*/
|
||||
const checkFileType = (
|
||||
fileOrName: File | string,
|
||||
supportedExtensions: readonly string[],
|
||||
mimeTypePrefix: string
|
||||
): boolean => {
|
||||
if (typeof fileOrName === 'string') {
|
||||
// 如果是字符串,检查扩展名
|
||||
const extension = getFileExtension(fileOrName)
|
||||
return supportedExtensions.includes(extension as any)
|
||||
}
|
||||
|
||||
// 如果是File对象,先检查MIME类型,再检查扩展名
|
||||
const file = fileOrName as File
|
||||
|
||||
// 优先检查MIME类型
|
||||
if (file.type && file.type.startsWith(mimeTypePrefix)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 如果MIME类型为空或不明确,检查文件扩展名
|
||||
const extension = getFileExtension(file.name)
|
||||
return supportedExtensions.includes(extension as any)
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据文件扩展名获取视频MIME类型
|
||||
* @param fileName 文件名
|
||||
* @returns 视频MIME类型
|
||||
*/
|
||||
export const getVideoMimeType = (fileName: string): string => {
|
||||
const extension = getFileExtension(fileName)
|
||||
return VIDEO_MIME_TYPE_MAP[extension] || 'video/mp4' // 默认返回mp4类型
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据文件扩展名获取音频MIME类型
|
||||
* @param fileName 文件名
|
||||
* @returns 音频MIME类型
|
||||
*/
|
||||
export const getAudioMimeType = (fileName: string): string => {
|
||||
const extension = getFileExtension(fileName)
|
||||
return AUDIO_MIME_TYPE_MAP[extension] || 'audio/mpeg' // 默认返回mp3类型
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据文件扩展名获取图片MIME类型
|
||||
* @param fileName 文件名
|
||||
* @returns 图片MIME类型
|
||||
*/
|
||||
export const getImageMimeType = (fileName: string): string => {
|
||||
const extension = getFileExtension(fileName)
|
||||
return IMAGE_MIME_TYPE_MAP[extension] || 'image/jpeg' // 默认返回jpeg类型
|
||||
}
|
||||
|
||||
/**
|
||||
* 修复文件的MIME类型(如果MIME类型为空或不正确)
|
||||
* @param file 原始文件
|
||||
* @returns 修复MIME类型后的文件
|
||||
*/
|
||||
export const fixFileMimeType = (file: File): File => {
|
||||
// 如果已经有正确的MIME类型,直接返回
|
||||
if (
|
||||
file.type &&
|
||||
(file.type.startsWith('video/') || file.type.startsWith('audio/') || file.type.startsWith('image/'))
|
||||
) {
|
||||
return file
|
||||
}
|
||||
|
||||
const extension = getFileExtension(file.name)
|
||||
let correctMimeType = ''
|
||||
|
||||
// 根据扩展名确定正确的MIME类型
|
||||
if (SUPPORTED_VIDEO_EXTENSIONS.includes(extension as any)) {
|
||||
correctMimeType = getVideoMimeType(file.name)
|
||||
} else if (SUPPORTED_AUDIO_EXTENSIONS.includes(extension as any)) {
|
||||
correctMimeType = getAudioMimeType(file.name)
|
||||
} else if (SUPPORTED_IMAGE_EXTENSIONS.includes(extension as any)) {
|
||||
correctMimeType = getImageMimeType(file.name)
|
||||
} else {
|
||||
// 如果不是媒体文件,保持原有类型
|
||||
return file
|
||||
}
|
||||
|
||||
// 创建新的File对象,修复MIME类型
|
||||
return new File([file], file.name, {
|
||||
type: correctMimeType,
|
||||
lastModified: file.lastModified
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据文件类型获取对应的消息枚举
|
||||
* @param file File对象
|
||||
* @returns 消息类型枚举
|
||||
*/
|
||||
export const getMessageTypeByFile = (file: File): MsgEnum => {
|
||||
if (checkFileType(file, SUPPORTED_VIDEO_EXTENSIONS, 'video/')) {
|
||||
return MsgEnum.VIDEO
|
||||
} else if (checkFileType(file, SUPPORTED_AUDIO_EXTENSIONS, 'audio/')) {
|
||||
return MsgEnum.VOICE
|
||||
} else if (checkFileType(file, SUPPORTED_IMAGE_EXTENSIONS, 'image/') && !file.type.includes('svg')) {
|
||||
return MsgEnum.IMAGE
|
||||
} else {
|
||||
return MsgEnum.FILE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查URL是否为视频链接
|
||||
* @param url 链接地址
|
||||
* @returns 是否为视频链接
|
||||
*/
|
||||
export const isVideoUrl = (url: string): boolean => {
|
||||
try {
|
||||
new URL(url)
|
||||
return checkFileType(url, SUPPORTED_VIDEO_EXTENSIONS, 'video/')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { MsgEnum } from '@/enums'
|
||||
import type { MsgType } from '@/services/types'
|
||||
|
||||
/**
|
||||
* 根据消息类型获取回复内容
|
||||
* @param message 消息对象
|
||||
* @returns 格式化后的回复内容
|
||||
*/
|
||||
export const getReplyContent = (message: MsgType): string => {
|
||||
let content: string
|
||||
|
||||
// 根据消息类型确定回复内容
|
||||
switch (message.type) {
|
||||
case MsgEnum.TEXT: {
|
||||
// 文本消息:显示原内容,处理
|
||||
content = message.body.content || ''
|
||||
if (typeof content === 'string') {
|
||||
content = content.replace(/ /g, ' ')
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case MsgEnum.VIDEO: {
|
||||
// 视频消息:使用缩略图URL或显示[视频]
|
||||
content = message.body.thumbUrl || '[视频]'
|
||||
break
|
||||
}
|
||||
|
||||
case MsgEnum.VOICE: {
|
||||
// 语音消息:显示 "[语音] X秒"
|
||||
const seconds = message.body.second || 0
|
||||
content = `[语音] ${seconds}秒`
|
||||
break
|
||||
}
|
||||
|
||||
case MsgEnum.FILE: {
|
||||
// 文件消息:显示文件名
|
||||
content = `[文件] ${message.body.fileName || ''}`
|
||||
break
|
||||
}
|
||||
|
||||
case MsgEnum.IMAGE: {
|
||||
// 图片消息:使用图片URL
|
||||
content = message.body.url || '[图片]'
|
||||
break
|
||||
}
|
||||
|
||||
case MsgEnum.NOTICE: {
|
||||
// 公告消息:显示内容
|
||||
content = `[公告] ${message.body.content || ''}`
|
||||
break
|
||||
}
|
||||
|
||||
case MsgEnum.SYSTEM: {
|
||||
// 系统消息
|
||||
content = '[系统消息]'
|
||||
break
|
||||
}
|
||||
|
||||
case MsgEnum.MERGE: {
|
||||
// 合并消息
|
||||
content = '[合并消息]'
|
||||
break
|
||||
}
|
||||
|
||||
case MsgEnum.AI: {
|
||||
// AI消息
|
||||
content = `'[AI消息]'${message.body.content || ''}`
|
||||
if (typeof content === 'string') {
|
||||
content = content.replace(/ /g, ' ')
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
default: {
|
||||
// 其他类型:尝试获取content或url
|
||||
content = message.body.content || message.body.url || '[未知消息]'
|
||||
if (typeof content === 'string') {
|
||||
content = content.replace(/ /g, ' ')
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
Reference in New Issue
Block a user