feat(component): 增加文件类型(除语音、视频)等文件类型的样式兼容

This commit is contained in:
Dawn
2025-07-04 05:56:38 +08:00
parent 916ab6c56a
commit 99711a73f3
15 changed files with 1500 additions and 244 deletions

Before

Width:  |  Height:  |  Size: 860 B

After

Width:  |  Height:  |  Size: 860 B

+1
View File
@@ -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
+9 -3
View File
@@ -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
View File
@@ -155,7 +155,9 @@ export enum StoresEnum {
/** 配置 */
CONFIG = 'config',
/** 视频查看器数据 */
VIDEOVIEWER = 'videoViewer'
VIDEOVIEWER = 'videoViewer',
/** 文件下载管理 */
FILE_DOWNLOAD = 'fileDownload'
}
/**
+18 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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: 处理回复内容
// 回复前把包含&nbsp;的字符替换成空格
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(/&nbsp;/g, ' ')
} else if (Array.isArray(content)) {
content = content.map((item: string) => {
return typeof item === 'string' ? item.replace(/&nbsp;/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 ? '视频' : '文件'}发送失败`)
}
}
}
+271
View File
@@ -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
View File
@@ -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 {
+1
View File
@@ -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']
+193
View File
@@ -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
}
}
+86
View File
@@ -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: {
// 文本消息:显示原内容,处理&nbsp;
content = message.body.content || ''
if (typeof content === 'string') {
content = content.replace(/&nbsp;/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(/&nbsp;/g, ' ')
}
break
}
default: {
// 其他类型:尝试获取content或url
content = message.body.content || message.body.url || '[未知消息]'
if (typeof content === 'string') {
content = content.replace(/&nbsp;/g, ' ')
}
break
}
}
return content
}