mcwl-pc/app/components/fileUpload/index.vue

567 lines
15 KiB
Vue

<script setup lang="ts">
import type { MessageReactive } from 'naive-ui'
import { useMessage } from 'naive-ui'
import SparkMD5 from 'spark-md5'
import { onMounted, ref } from 'vue'
// const { calculateHash } = useFileHash();
// const { calculateHash, isCalculating } = useFileHash()
// debugger
// Props 定义
const props = defineProps({
accept: {
type: Array as PropType<string[]>,
default: () => ['doc', 'docx', 'pdf', 'safetensors', 'ckpt'],
},
required: {
type: Boolean,
default: false,
},
fileSize: {
type: Number,
default: 2000,
},
verifyHash: {
type: Boolean,
default: false,
},
verifyName: {
type: Boolean,
default: false,
},
type: {
type: String,
default: 'model',
},
})
// 定义上传完成后发送的事件
const emit = defineEmits<{
'upload-success': [
{
objectKey: string
objectUrl: string
fileName: string
currentFileSize: number
hashCode: number
},
]
}>()
const messageReactive: MessageReactive | null = null
// 响应式状态
const fileInputRef = ref<HTMLInputElement | null>(null)
const file = ref<File | null>(null)
const fileName = ref('')
const fileSize = ref(0)
const progress = ref(0)
const objectKey = ref('')
const chunkSize = ref(5 * 1024 * 1024)
const totalChunks = ref(0)
const currentChunk = ref(1)
const uploadId = ref('')
const partArr = ref([])
const showFileName = ref(false)
const bucketName = ref('')
const objectKeyName = ref('')
const requiredValid = ref(false)
const hashCode = ref('')
const message = useMessage()
const currentFileSize = ref(0)
// 添加新的状态来跟踪上传状态
const uploadStatus = ref<'idle' | 'uploading' | 'paused' | 'completed'>('idle')
const uploadedChunks = ref<Set<number>>(new Set())
const retryCount = ref<number>(0)
const maxRetries = 5 // 最大重试次数
// 触发文件选择
function triggerFileSelect() {
fileInputRef.value?.click()
}
// 获取接受的文件类型
function getAcceptType() {
return props.accept.join(',')
}
// 添加计算hash的函数
function calculateFileHash(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.readAsArrayBuffer(file)
reader.onload = (event) => {
try {
const buffer = event.target?.result as ArrayBuffer
const spark = new SparkMD5.ArrayBuffer()
spark.append(buffer)
const hash = spark.end()
resolve(hash)
}
catch (error) {
reject(error)
}
}
reader.onerror = () => {
reject(new Error('Failed to read file'))
}
})
}
// const calculateFileHash = (file: File): Promise<string> => {
// return new Promise((resolve, reject) => {
// const reader = new FileReader();
// reader.readAsArrayBuffer(file);
// reader.onload = async (event) => {
// try {
// const buffer = event.target?.result as ArrayBuffer;
// const hashBuffer = await window.crypto.subtle.digest("SHA-256", buffer);
// const hashArray = Array.from(new Uint8Array(hashBuffer));
// const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
// resolve(hashHex);
// } catch (error) {
// reject(error);
// }
// };
// reader.onerror = () => {
// reject(new Error("Failed to read file"));
// };
// });
// };
// 生成唯一ID
// const guid = () => {
// return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
// const r = (Math.random() * 16) | 0;
// const v = c === "x" ? r : (r & 0x3) | 0x8;
// return v.toString(16);
// });
// };
// 初始化参数
function initParam() {
file.value = null
objectKey.value = ''
totalChunks.value = 0
currentChunk.value = 1
uploadId.value = ''
partArr.value = []
requiredValid.value = false
uploadStatus.value = 'idle'
uploadedChunks.value.clear()
retryCount.value = 0
}
function getOssDefaultPath(name: any) {
const timestamp = Date.now()
const now = new Date()
const year = now.getFullYear() // 获取当前年份
const month = String(now.getMonth() + 1).padStart(2, '0') // 获取当前月份,确保两位
const day = String(now.getDate()).padStart(2, '0') // 获取当前日期,确保两位
return `${year}/${month}/${day}/${timestamp}/${name}` // 拼接路径
}
// 选择文件处理
async function selectFile(event: Event) {
const target = event.target as HTMLInputElement
if (!target.files?.length)
return
file.value = target.files[0]
target.value = ''
if (file.value?.size) {
currentFileSize.value = file.value?.size
// 文件大小检查
const calculatedFileSize = Math.round((file.value.size / 1024 / 1024) * 100) / 100
if (calculatedFileSize > props.fileSize) {
message.warning(`文件大小超过上限${props.fileSize}MB`)
return
}
fileSize.value = calculatedFileSize
// 文件类型检查
const selectedFileName = file.value.name
const fileType = selectedFileName.split('.').pop()?.toLowerCase()
if (!fileType || !props.accept.includes(fileType)) {
message.warning('文件类型不支持')
return
}
try {
// 如果存在未完成的上传,询问是否继续
if (uploadStatus.value === 'paused' && file.value) {
const shouldResume = window.confirm('检测到未完成的上传,是否继续上传?')
if (shouldResume) {
await resumeUpload()
return
}
else {
// 用户选择重新上传,清除之前的上传状态
initParam()
}
}
// 继续上传流程
progress.value = 0
fileName.value = file.value.name
showFileName.value = true
// 计算文件hash
if (props.verifyHash) {
message.info('正在校验文件hash...')
try {
hashCode.value = await calculateFileHash(file.value)
}
catch (err) {
message.warning(err)
hashCode.value = ''
}
finally {
// messageReactive.destroy();
// messageReactive = null;
}
// hashCode.value = await calculateHash(file.value)
// hashCode.value = await calculateFileHash(file.value);
// 检查文件是否已存在
try {
const hashRes = await request.get(
`/file/selectHash?hashCode=${hashCode.value}&type=${
props.type === 'model' ? 0 : 1
}`,
)
if (hashRes.data !== 1) {
message.warning('文件已存在')
return
}
}
finally {
if (messageReactive) {
// messageReactive.destroy();
// messageReactive = null;
}
}
}
// 校验名称
if (props.verifyName) {
message.info('正在校验文件名...')
// 检查文件是否已存在
// 上传文件前先校验是否存在 存在为0 不存在为1
try {
// const res = await request.get(
// `/file/selectFile?type=${props.type}&name=${fileName.value}`
// );
const res = await request.post(
`/file/selectFile`,
{
type: props.type,
name: fileName.value,
},
)
if (res.data !== 1) {
message.warning('文件名已存在')
return
}
}
finally {
if (messageReactive) {
// messageReactive.destroy();
// messageReactive = null;
}
}
}
totalChunks.value = Math.ceil(file.value.size / chunkSize.value)
objectKey.value = `${getOssDefaultPath(fileName.value)}`
// 获取上传ID
const res = await request.get(`/file/getUploadId?objectKey=${objectKey.value}`)
uploadId.value = res.data
progress.value = 1
uploadFile()
}
catch (error) {
console.error('文件处理失败:', error)
message.error('文件处理失败')
initParam()
}
}
// const target = event.target as HTMLInputElement;
// if (!target.files?.length) return;
// file.value = target.files[0];
// target.value = "";
// if (file.value?.size) {
// const calculatedFileSize = Math.round((file.value.size / 1024 / 1024) * 100) / 100;
// if (calculatedFileSize > props.fileSize) {
// message.warning(`文件大小超过上限${props.fileSize}MB`);
// return;
// }
// fileSize.value = calculatedFileSize;
// const selectedFileName = file.value.name;
// const fileType = selectedFileName.split(".").pop()?.toLowerCase();
// if (!fileType || !props.accept.includes(fileType)) {
// message.warning("文件类型不支持");
// return;
// }
// progress.value = 0;
// fileName.value = file.value.name;
// showFileName.value = true;
// totalChunks.value = Math.ceil(file.value.size / chunkSize.value);
// objectKey.value = `${getOssDefaultPath(fileName.value, guid())}`;
// try {
// const res = await request.get(`/file/getUploadId?objectKey=${objectKey.value}`);
// uploadId.value = res.data;
// progress.value = 1;
// uploadFile();
// } catch (error) {
// message.error("获取上传ID失败");
// }
// }
}
// 文件上传
async function uploadFile() {
if (!file.value)
return
while (currentChunk.value <= totalChunks.value) {
// 如果这个分片已经上传成功,跳过
if (uploadedChunks.value.has(currentChunk.value)) {
currentChunk.value++
continue
}
const index = currentChunk.value - 1
const start = index * chunkSize.value
const end = Math.min((index + 1) * chunkSize.value, file.value.size)
const formData = new FormData()
formData.append('file', file.value.slice(start, end))
formData.append('chunk', String(currentChunk.value))
formData.append('objectKey', objectKey.value)
formData.append('uploadId', uploadId.value)
try {
uploadStatus.value = 'uploading'
const res = await request.post('/file/chunk', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
// 添加上传进度监听
onUploadProgress: (progressEvent) => {
const percentCompleted = Math.round(
((currentChunk.value - 1) / totalChunks.value) * 100
+ (progressEvent.loaded / progressEvent.total!) * (100 / totalChunks.value),
)
progress.value = percentCompleted
},
})
// 记录成功上传的分片
uploadedChunks.value.add(currentChunk.value)
partArr.value.push(res.data)
currentChunk.value++
retryCount.value = 0 // 重置重试计数
progress.value = Math.floor((currentChunk.value / totalChunks.value) * 100)
}
catch (error) {
console.error('切片上传失败:', error)
// 重试逻辑
if (retryCount.value < maxRetries) {
retryCount.value++
message.warning(`${currentChunk.value} 个分片上传失败,正在进行第 ${retryCount.value} 次重试...`)
await new Promise(resolve => setTimeout(resolve, 1000)) // 等待1秒后重试
continue // 重试当前分片
}
// 超过最大重试次数
uploadStatus.value = 'paused'
message.error(`上传失败,已暂停。您可以稍后继续上传。`)
return
}
}
// 所有分片上传完成
if (currentChunk.value > totalChunks.value) {
progress.value = 99
uploadStatus.value = 'completed'
await complete()
}
}
// 添加继续上传方法
async function resumeUpload() {
if (uploadStatus.value === 'paused' && file.value) {
retryCount.value = 0 // 重置重试计数
message.info('正在继续上传...')
await uploadFile()
}
}
// 完成上传
async function complete() {
try {
const res = await request.post(
`/file/completeUpload?objectKey=${objectKey.value}&uploadId=${uploadId.value}`,
partArr.value,
)
bucketName.value = res.data.bucketName
objectKeyName.value = res.data.objectKey
progress.value = 100
// 发送上传成功事件
emit('upload-success', {
objectKey: objectKeyName.value,
objectUrl: res.data.objectUrl, // 假设后端返回了文件路径
fileName: fileName.value,
currentFileSize: currentFileSize.value,
hashCode: hashCode.value,
})
initParam()
}
catch (error) {
console.error('文件上传失败:', error)
message.warning('文件上传失败')
}
}
// 取消上传
async function cancelUploadInfo() {
if (uploadStatus.value !== 'completed') {
try {
await request.post('/file/cancelUpload', {
objectKey: objectKey.value,
uploadId: uploadId.value,
})
uploadStatus.value = 'idle'
uploadedChunks.value.clear()
progress.value = 0
showFileName.value = false
initParam()
}
catch (error) {
message.error('取消上传失败')
}
}
}
// 校验是否已上传
function validRequired() {
if (bucketName.value && objectKeyName.value) {
requiredValid.value = false
return true
}
requiredValid.value = true
return false
}
// 获取上传信息
function getUploadInfo() {
return {
bucketName: bucketName.value,
fileName: objectKeyName.value,
fileSize: fileSize.value,
}
}
// 初始化
onMounted(() => {
progress.value = 0
})
// 明确暴露方法
defineExpose({
triggerFileSelect,
validRequired,
getUploadInfo,
})
</script>
<template>
<div class="upload-container">
<div class="fileUpload">
<div class="item-upload">
<!-- <span v-if="required" class="required">*</span> -->
<!-- <n-button type="success" @click="triggerFileSelect"> 文件选择 </n-button> -->
<input
ref="fileInputRef"
type="file"
class="file2"
style="display: none"
@change="selectFile"
>
</div>
<!-- <div v-if="showFileName" class="item-info">
{{ fileName }}
</div>
<div v-else class="item-info">支持{{ getAcceptType() }}文件格式</div>
<div v-if="progress > 0" class="item-cancel">
<n-button type="success" style="margin-top: 5px" @click="cancelUploadInfo">
X
</n-button>
</div> -->
</div>
<div v-if="progress > 0" class="item-process">
<n-progress
type="line"
:percentage="progress"
indicator-placement="inside"
processing
/>
</div>
<!-- <div v-if="requiredValid" class="item-valid">请上传文件</div> -->
<!-- <div class="resume-upload">
<n-button type="primary" @click="resumeUpload">
继续上传
</n-button>
</div> -->
</div>
</template>
<style scoped lang="scss">
.upload-container {
.fileUpload {
display: flex;
flex-direction: row;
padding: 0px 8px;
.item-upload {
width: 100px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
.required {
color: red;
padding: 0px 5px;
}
}
.item-info {
flex: 1;
padding-top: 12px;
font-size: 13px;
}
.item-cancel {
width: 30px;
padding-right: 20px;
}
}
.item-process {
margin-top: 10px;
/* padding: 0px 23px; */
box-sizing: border-box;
}
.item-valid {
color: red;
padding: 0px 10px;
font-size: 13px;
}
}
</style>