This commit is contained in:
iqudoo
2026-06-05 17:22:32 +08:00
commit eb4e8f1a04
90 changed files with 21224 additions and 0 deletions

View File

@@ -0,0 +1,665 @@
<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>