Files
springboot-shop-sysadmin/src/iview/form/IViewFormUploadFile.vue
iqudoo eb4e8f1a04 init
2026-06-05 17:22:32 +08:00

666 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="iqudoo-form-file-upload">
<div>
<!-- 文件列表 -->
<div class="file-list-wrapper" v-if="fileFormModel.length > 0">
<a-list size="small" :data-source="fileFormModel" class="file-list">
<template #renderItem="{ item, index }">
<a-list-item>
<div
class="file-item"
:class="{
dragging: draggingIndex === index,
'drag-over': draggedOverIndex === index && draggingIndex !== index,
}"
:draggable="dragToSort && !field.disabled && !readOnly"
@dragstart="handleDragStart(index)"
@dragend="handleDragEnd"
@dragover="handleDragOver"
@dragenter="handleDragEnter(index)"
@dragleave="handleDragLeave"
@drop="handleDrop(index)"
>
<div class="file-icon">
<file-text-outlined v-if="isFileType(item, 'txt')" />
<file-pdf-outlined v-else-if="isFileType(item, 'pdf')" />
<file-excel-outlined v-else-if="isFileType(item, 'excel')" />
<file-word-outlined v-else-if="isFileType(item, 'word')" />
<file-zip-outlined v-else-if="isFileType(item, 'zip')" />
<file-outlined v-else />
</div>
<div class="file-content">
<div class="file-name-row">
<a class="file-name" :href="item" target="_blank">
{{ getFileName(item) }}
</a>
<div class="file-actions" v-if="!field.disabled && !readOnly">
<a-button type="link" @click="downloadFile(item)">
<template #icon><download-outlined /></template>
</a-button>
<a-button type="link" danger @click="removeFile(index)">
<template #icon><delete-outlined /></template>
</a-button>
</div>
</div>
<div class="file-info">{{ getFileTypeLabel(item) }}</div>
</div>
<div
class="drag-handle"
v-if="dragToSort && !field.disabled && !readOnly"
>
<holder-outlined />
</div>
</div>
</a-list-item>
</template>
</a-list>
</div>
<!-- 上传触发区域 -->
<div
v-show="readOnly || fileFormModel.length < field.fileCount"
class="upload-trigger"
>
<a-upload
:id="'form_item_' + formatFieldName(field.name)"
:max-count="field.fileCount"
:show-upload-list="false"
:before-upload="beforeUpload"
:accept="acceptedImageTypes"
:multiple="multipleSelect"
list-type="picture-card"
>
<div class="upload-button">
<a-spin :spinning="loading">
<plus-outlined />
<div class="upload-text">
{{ multipleSelect ? "选择多个文件" : "上传文件" }}
</div>
</a-spin>
</div>
</a-upload>
</div>
</div>
<div class="upload-hint">
<span>
{{ fileFormModel.length }}/{{ field.fileCount }}
<template v-if="field.fileSize"> · 最大{{ field.fileSize }}MB </template>
<template v-if="acceptDescription"> · 支持{{ acceptDescription }} </template>
<template v-if="multipleSelect"> · 支持多选和拖动排序 </template>
</span>
</div>
</div>
</template>
<script setup>
import { ref, computed } from "vue";
import { useVModel } from "@/iview/utils/useModel";
import { apiRequest } from "@/iview/utils/request";
import { showErrorTips } from "@/iview/utils/messageTips";
import {
DeleteOutlined,
DownloadOutlined,
PlusOutlined,
FileTextOutlined,
FilePdfOutlined,
FileExcelOutlined,
FileWordOutlined,
FileZipOutlined,
FileOutlined,
HolderOutlined,
} from "@ant-design/icons-vue";
const props = defineProps({
field: {
type: Object,
required: true,
},
accept: {
type: String,
default: ".pdf,.doc,.docx,.xls,.xlsx,.zip,.rar,.txt",
},
multipleSelect: {
type: Boolean,
default: false,
},
dragToSort: {
type: Boolean,
default: true,
},
fieldPrefix: {
type: String,
default: "",
},
modelValue: {
type: Object,
required: true,
},
readOnly: {
type: Boolean,
default: false,
},
rawFormData: {
type: Object,
},
});
const emit = defineEmits(["update:modelValue"]);
const vModel = useVModel(props, "modelValue", emit);
const loading = ref(false);
const draggingIndex = ref(-1);
const draggedOverIndex = ref(-1);
const dragPlaceholder = ref(null);
const acceptDescription = computed(() => {
const types = props.accept
.split(",")
.map((type) => {
return type.replace(".", "").toUpperCase();
})
.join("、");
return types;
});
const acceptedImageTypes = computed(() => {
return (
props.accept ||
"application/pdf,application/excel,application/word,application/zip,text/plain"
);
});
const formatFieldName = (name) => {
if (!props.fieldPrefix) {
return name;
}
return props.fieldPrefix + "-" + name;
};
// 判断文件类型
const isFileType = (url, type) => {
const fileName = getFileName(url).toLowerCase();
if (type === "pdf") {
return fileName.endsWith(".pdf");
} else if (type === "excel") {
return fileName.endsWith(".xls") || fileName.endsWith(".xlsx");
} else if (type === "word") {
return fileName.endsWith(".doc") || fileName.endsWith(".docx");
} else if (type === "zip") {
return fileName.endsWith(".zip") || fileName.endsWith(".rar");
} else if (type === "txt") {
return fileName.endsWith(".txt") || fileName.endsWith(".log");
}
return true;
};
// 获取文件类型标签
const getFileTypeLabel = (url) => {
const fileName = getFileName(url).toLowerCase();
let fileType = "文件";
if (fileName.endsWith(".pdf")) {
fileType = "PDF文档";
} else if (fileName.endsWith(".doc") || fileName.endsWith(".docx")) {
fileType = "Word文档";
} else if (fileName.endsWith(".xls") || fileName.endsWith(".xlsx")) {
fileType = "Excel表格";
} else if (fileName.endsWith(".zip") || fileName.endsWith(".rar")) {
fileType = "压缩包";
} else if (fileName.endsWith(".txt")) {
fileType = "文本文件";
}
return fileType;
};
const getFileName = (url) => {
if (!url) return "未知文件";
const parts = url.split("/");
return parts[parts.length - 1];
};
const removeFile = (index) => {
let newFileList = [...fileFormModel.value];
newFileList.splice(index, 1);
vModel.value[formatFieldName(props.field.name)] = newFileList.join("|");
};
const downloadFile = (url) => {
if (!url) return;
window.open(url, "_blank");
};
const beforeUpload = async (file, fileList) => {
// 如果是多选模式且是第一个文件,处理所有文件
if (
props.multipleSelect &&
fileList &&
fileList.length > 1 &&
fileList.indexOf(file) === 0
) {
await uploadMultipleFiles(fileList);
return false;
}
// 如果是多选模式但不是第一个文件,跳过处理
if (
props.multipleSelect &&
fileList &&
fileList.length > 1 &&
fileList.indexOf(file) > 0
) {
return false;
}
// 单个文件处理
if (props.accept) {
const acceptTypes = props.accept.split(",");
const fileName = file.name.toLowerCase();
const isAccepted = acceptTypes.some((type) => {
return fileName.endsWith(type.replace(".", ""));
});
if (!isAccepted) {
let error = new Error(`只能上传${acceptDescription.value}格式的文件!`);
showErrorTips(error);
return false;
}
}
if (
props.field.fileCount &&
props.field.fileCount > 0 &&
fileFormModel.value.length >= props.field.fileCount
) {
let error = new Error(`最多只能上传${props.field.fileCount}个文件`);
showErrorTips(error);
return false;
}
if (
props.field.fileSize &&
props.field.fileSize > 0 &&
file.size > props.field.fileSize * 1024 * 1024
) {
let error = new Error(`文件大小不能超过${props.field.fileSize}MB`);
showErrorTips(error);
return false;
}
await uploadFile(file);
return false;
};
const uploadFile = async (file) => {
try {
loading.value = true;
const base64 = await fileToBase64(file);
const result = await apiRequest("api.system.global.file.upload", {
fileBase64: base64.split(",")[1],
type: "file",
fileName: file.name,
});
if (!result || !result.url) {
throw new Error("文件上传失败未获取到URL");
}
let newFileList = [...fileFormModel.value];
newFileList.push(result.url);
vModel.value[formatFieldName(props.field.name)] = newFileList.join("|");
} catch (error) {
showErrorTips(error);
} finally {
loading.value = false;
}
};
const fileToBase64 = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const base64String = reader.result;
resolve(base64String);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
const fileFormModel = computed(() => {
return vModel.value[formatFieldName(props.field.name)]
.split("|")
.filter((item) => !!item);
});
// 多文件上传
const uploadMultipleFiles = async (fileList) => {
try {
loading.value = true;
// 检查文件数量限制
const currentFileCount = fileFormModel.value.length;
const maxFileCount = props.field.fileCount || 0;
const remainingSlots = maxFileCount - currentFileCount;
if (remainingSlots <= 0) {
let error = new Error(`最多只能上传${maxFileCount}个文件`);
showErrorTips(error);
return;
}
// 验证每个文件的大小和类型
const validFiles = [];
for (const file of fileList) {
// 检查文件类型
if (props.accept) {
const acceptTypes = props.accept.split(",");
const fileName = file.name.toLowerCase();
const isAccepted = acceptTypes.some((type) => {
return fileName.endsWith(type.replace(".", ""));
});
if (!isAccepted) {
showErrorTips(
new Error(
`文件 ${file.name} 格式不支持,只支持${acceptDescription.value}格式`
)
);
continue;
}
}
// 检查文件大小
if (
props.field.fileSize &&
props.field.fileSize > 0 &&
file.size > props.field.fileSize * 1024 * 1024
) {
showErrorTips(
new Error(`文件 ${file.name} 大小超过限制,最大${props.field.fileSize}MB`)
);
continue;
}
validFiles.push(file);
// 如果已达到剩余槽位限制,停止添加
if (validFiles.length >= remainingSlots) {
break;
}
}
if (validFiles.length === 0) {
showErrorTips(new Error("没有有效的文件可以上传"));
return;
}
if (validFiles.length < fileList.length) {
showErrorTips(
new Error(
`选择了${fileList.length}个文件,但只有${validFiles.length}个文件符合要求,已自动过滤无效文件`
)
);
}
if (validFiles.length > remainingSlots) {
showErrorTips(
new Error(
`最多还能上传${remainingSlots}个文件,已自动选择前${remainingSlots}个有效文件`
)
);
}
// 限制本次上传的文件数量
const filesToUpload = validFiles.slice(0, remainingSlots);
const uploadPromises = filesToUpload.map((file) => uploadSingleFile(file));
const results = await Promise.all(uploadPromises);
// 过滤掉失败的上传
const successfulUploads = results.filter((result) => result && result.url);
if (successfulUploads.length > 0) {
let newFileList = [...fileFormModel.value];
newFileList.push(...successfulUploads.map((result) => result.url));
vModel.value[formatFieldName(props.field.name)] = newFileList.join("|");
}
} catch (error) {
showErrorTips(error);
} finally {
loading.value = false;
}
};
// 单个文件上传不更新UI状态
const uploadSingleFile = async (file) => {
try {
const base64 = await fileToBase64(file);
const result = await apiRequest("api.system.global.file.upload", {
fileBase64: base64.split(",")[1],
type: "file",
fileName: file.name,
});
return result;
} catch (error) {
console.error("文件上传失败:", error);
return null;
}
};
// 拖动排序功能
const handleDragStart = (index) => {
if (!props.dragToSort || props.field.disabled || props.readOnly) return;
draggingIndex.value = index;
draggedOverIndex.value = -1;
// 设置拖拽数据
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/html", index.toString());
};
const handleDragEnd = () => {
draggingIndex.value = -1;
draggedOverIndex.value = -1;
};
const handleDragOver = (event) => {
if (!props.dragToSort || props.field.disabled || props.readOnly) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
};
const handleDragEnter = (index) => {
if (!props.dragToSort || props.field.disabled || props.readOnly) return;
if (draggingIndex.value !== -1 && draggingIndex.value !== index) {
draggedOverIndex.value = index;
}
};
const handleDragLeave = (event) => {
if (!props.dragToSort || props.field.disabled || props.readOnly) return;
// 只有当离开整个拖拽区域时才清除dragOver状态
const relatedTarget = event.relatedTarget;
if (!relatedTarget || !event.currentTarget.contains(relatedTarget)) {
draggedOverIndex.value = -1;
}
};
const handleDrop = (dropIndex) => {
if (!props.dragToSort || props.field.disabled || props.readOnly) return;
try {
event.preventDefault();
} catch (_e) {}
const dragIndex = draggingIndex.value;
if (dragIndex === -1 || dragIndex === dropIndex) {
draggingIndex.value = -1;
draggedOverIndex.value = -1;
return;
}
const newFileList = [...fileFormModel.value];
const draggedItem = newFileList[dragIndex];
// 移除被拖动的项目
newFileList.splice(dragIndex, 1);
// 计算正确的插入位置
let insertIndex = dropIndex;
// 在目标位置插入
newFileList.splice(insertIndex, 0, draggedItem);
// 更新数据
vModel.value[formatFieldName(props.field.name)] = newFileList.join("|");
draggingIndex.value = -1;
draggedOverIndex.value = -1;
};
</script>
<style lang="less" scoped>
.iqudoo-form-file-upload {
width: 100%;
.ant-list-item {
padding: 8px;
}
.file-list-wrapper {
margin-bottom: 16px;
}
.file-list {
border: 1px solid #f0f0f0;
border-radius: 8px;
overflow: hidden;
background-color: #fafafa;
}
.file-item {
display: flex;
width: 100%;
align-items: flex-start;
padding: 8px;
cursor: move;
transition: all 0.2s ease;
border-radius: 8px;
position: relative;
border: 1px solid transparent;
}
.file-item:hover {
background-color: #f5f5f5;
}
.file-item.dragging {
opacity: 0.6;
transform: rotate(1deg) scale(1.01);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
background-color: #fff;
border-color: #1890ff;
}
.file-item.drag-over {
border: 2px dashed #1890ff;
background-color: rgba(24, 144, 255, 0.05);
transform: scale(1.01);
}
.file-icon {
font-size: 24px;
margin-right: 12px;
color: #1890ff;
}
.file-content {
flex: 1;
min-width: 0;
}
.file-name-row {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.file-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #1890ff;
font-weight: 500;
}
.file-actions {
display: flex;
white-space: nowrap;
}
.drag-handle {
margin-left: 8px;
color: #999;
cursor: move;
display: flex;
align-items: center;
padding: 4px;
border-radius: 4px;
transition: all 0.2s;
}
.drag-handle:hover {
color: #1890ff;
background-color: #f0f0f0;
}
.file-info {
font-size: 12px;
color: #999;
margin-top: 2px;
}
.upload-trigger {
margin-bottom: 16px;
}
.upload-button {
height: 38px;
border-radius: 6px;
width: 100%;
max-width: 200px;
}
.upload-hint {
font-size: 12px;
color: #999;
display: flex;
flex-wrap: wrap;
}
@media (max-width: 768px) {
.file-name-row {
flex-direction: column;
align-items: flex-start;
}
.file-actions {
margin-top: 4px;
}
.upload-hint {
flex-direction: column;
gap: 4px;
}
}
}
</style>