forked from tools/tape-springboot-sysadmin
666 lines
17 KiB
Vue
666 lines
17 KiB
Vue
<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>
|