555 lines
15 KiB
Vue
555 lines
15 KiB
Vue
<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
|
|
type="file"
|
|
ref="fileInputRef"
|
|
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 class="item-process" v-if="progress > 0">
|
|
<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>
|
|
|
|
<script setup lang="ts">
|
|
import { MessageReactive, useMessage } from "naive-ui";
|
|
import SparkMD5 from 'spark-md5';
|
|
import { onMounted, ref } from "vue";
|
|
const messageReactive: MessageReactive | null = null;
|
|
// 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 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; // 最大重试次数
|
|
|
|
// 触发文件选择
|
|
const triggerFileSelect = () => {
|
|
fileInputRef.value?.click();
|
|
};
|
|
|
|
// 获取接受的文件类型
|
|
const getAcceptType = () => {
|
|
return props.accept.join(",");
|
|
};
|
|
|
|
// 添加计算hash的函数
|
|
const 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);
|
|
// });
|
|
// };
|
|
|
|
// 初始化参数
|
|
const 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}`; // 拼接路径
|
|
}
|
|
// 选择文件处理
|
|
const selectFile = async (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失败");
|
|
// }
|
|
// }
|
|
};
|
|
|
|
// 文件上传
|
|
const uploadFile = async () => {
|
|
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();
|
|
}
|
|
};
|
|
|
|
// 添加继续上传方法
|
|
const resumeUpload = async () => {
|
|
if (uploadStatus.value === 'paused' && file.value) {
|
|
retryCount.value = 0; // 重置重试计数
|
|
message.info('正在继续上传...');
|
|
await uploadFile();
|
|
}
|
|
};
|
|
|
|
// 完成上传
|
|
const complete = async () => {
|
|
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("文件上传失败");
|
|
}
|
|
};
|
|
|
|
// 定义上传完成后发送的事件
|
|
const emit = defineEmits<{
|
|
"upload-success": [
|
|
{
|
|
objectKey: string;
|
|
objectUrl: string;
|
|
fileName: string;
|
|
currentFileSize: number;
|
|
hashCode: number;
|
|
}
|
|
];
|
|
}>();
|
|
|
|
// 取消上传
|
|
const cancelUploadInfo = async () => {
|
|
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("取消上传失败");
|
|
}
|
|
}
|
|
};
|
|
|
|
// 校验是否已上传
|
|
const validRequired = () => {
|
|
if (bucketName.value && objectKeyName.value) {
|
|
requiredValid.value = false;
|
|
return true;
|
|
}
|
|
requiredValid.value = true;
|
|
return false;
|
|
};
|
|
|
|
// 获取上传信息
|
|
const getUploadInfo = () => {
|
|
return {
|
|
bucketName: bucketName.value,
|
|
fileName: objectKeyName.value,
|
|
fileSize: fileSize.value,
|
|
};
|
|
};
|
|
|
|
// 初始化
|
|
onMounted(() => {
|
|
progress.value = 0;
|
|
});
|
|
|
|
// 明确暴露方法
|
|
defineExpose({
|
|
triggerFileSelect,
|
|
validRequired,
|
|
getUploadInfo,
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.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>
|