567 lines
15 KiB
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>
|