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

624 lines
15 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-image-upload">
<div class="image-grid">
<div
v-if="fileFormModel.length > 0"
v-for="(item, index) in fileFormModel"
class="image-item"
:class="{
dragging: draggingIndex === index,
'drag-over': draggedOverIndex === index && draggingIndex !== index,
}"
:key="index"
:draggable="dragToSort && !field.disabled && !readOnly"
@dragstart="handleDragStart(index)"
@dragend="handleDragEnd"
@dragover="handleDragOver"
@dragenter="handleDragEnter(index)"
@dragleave="handleDragLeave"
@drop="handleDrop(index)"
>
<div style="height: 120px; width: 120px">
<IDisplayImageView
:image-url="item"
:show-count="1"
width="120px"
height="120px"
/>
</div>
<a-popconfirm
title="确定要删除这张图片吗?"
v-if="!field.disabled"
ok-text="删除"
cancel-text="取消"
@confirm="removeFile(index)"
placement="topRight"
>
<div class="delete-button-wrapper">
<a-button type="danger" shape="circle" size="small" class="mobile-delete-btn">
<template #icon><delete-outlined /></template>
</a-button>
</div>
</a-popconfirm>
</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"
>
<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="supportedFormats"> · 支持{{ supportedFormats }} </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 { formatImageOutputHint, processImageFile } from "@/iview/utils/imageOutput";
import { DeleteOutlined, PlusOutlined } from "@ant-design/icons-vue";
import IDisplayImageView from "@/iview/display/IDisplayImageView.vue";
const props = defineProps({
field: {
type: Object,
required: true,
},
accept: {
type: String,
default: "image/jpeg,image/png,image/gif",
},
fieldPrefix: {
type: String,
default: "",
},
multipleSelect: {
type: Boolean,
default: false,
},
dragToSort: {
type: Boolean,
default: true,
},
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 supportedFormats = computed(() => {
return props.accept
.split(",")
.map((type) => type.replace("image/", ""))
.join("、");
});
const acceptedImageTypes = computed(() => {
return props.accept || "image/jpeg,image/png,image/gif";
});
const outputSizeHint = computed(() => formatImageOutputHint(props.field));
const formatFieldName = (name) => {
if (!props.fieldPrefix) {
return name;
}
return props.fieldPrefix + "-" + name;
};
const removeFile = (index) => {
let newFileList = [...fileFormModel.value];
newFileList.splice(index, 1);
vModel.value[formatFieldName(props.field.name)] = newFileList.join("|");
};
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;
}
// 单个文件处理
const isAcceptedType = acceptedImageTypes.value.includes(file.type);
if (!isAcceptedType) {
let error = new Error(`只能上传${supportedFormats.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 processImageFile(file, props.field);
const result = await apiRequest("api.system.global.file.upload", {
fileBase64: base64.split(",")[1],
type: "image",
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 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) {
// 检查文件类型
const isAcceptedType = acceptedImageTypes.value.includes(file.type);
if (!isAcceptedType) {
showErrorTips(
new Error(`图片 ${file.name} 格式不支持,只支持${supportedFormats.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 processImageFile(file, props.field);
const result = await apiRequest("api.system.global.file.upload", {
fileBase64: base64.split(",")[1],
type: "image",
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-image-upload {
width: 100%;
.image-grid {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.image-item {
width: 120px;
height: 120px;
display: flex;
flex-direction: column;
position: relative;
border-radius: 8px;
overflow: hidden;
border: 1px solid #f0f0f0;
transition: all 0.2s ease;
cursor: move;
}
.image-item.dragging {
opacity: 0.6;
transform: rotate(1deg) scale(1.03);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
z-index: 1000;
border-color: #1890ff;
}
.image-item.drag-over {
border: 2px dashed #1890ff;
background-color: rgba(24, 144, 255, 0.05);
transform: scale(1.01);
}
.image-item:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.delete-button-wrapper {
position: absolute;
top: 5px;
right: 5px;
z-index: 10;
}
.drag-handle {
position: absolute;
bottom: 5px;
left: 5px;
z-index: 10;
color: rgba(255, 255, 255, 0.8);
background-color: rgba(0, 0, 0, 0.5);
border-radius: 4px;
padding: 4px;
cursor: move;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
font-size: 12px;
}
.drag-handle:hover {
background-color: rgba(0, 0, 0, 0.7);
color: #fff;
}
.delete-button-wrapper .ant-btn-circle {
min-width: 24px;
width: 24px;
height: 24px;
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
}
.mobile-delete-btn {
background-color: rgba(255, 77, 79, 0.9) !important;
transition: all 0.2s;
border: none;
color: #fff;
}
.mobile-delete-btn:hover,
.mobile-delete-btn:active {
background-color: rgb(255, 77, 79) !important;
}
.image-preview:hover img {
transform: scale(1.05);
}
.image-name {
padding: 4px 8px;
font-size: 12px;
color: #666;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background-color: #f9f9f9;
}
.image-actions {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.4);
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
opacity: 0;
transition: opacity 0.3s;
}
.image-item:hover .image-actions {
opacity: 1;
}
.upload-trigger {
margin-bottom: 0px;
}
.upload-button {
width: 120px;
height: 120px;
border-radius: 8px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: #999;
cursor: pointer;
transition: all 0.3s;
border: 2px dashed #d9d9d9;
background-color: #fafafa;
text-align: center;
}
.upload-button:hover {
color: #1890ff;
}
.upload-text {
margin-top: 8px;
font-size: 14px;
}
.upload-hint {
font-size: 12px;
color: #999;
margin-top: 8px;
display: flex;
flex-wrap: wrap;
}
@media (max-width: 768px) {
.image-grid {
gap: 8px;
}
.upload-hint {
flex-direction: column;
gap: 4px;
}
.delete-button-wrapper {
display: block;
}
.delete-button-wrapper .ant-btn-circle {
min-width: 28px;
width: 28px;
height: 28px;
font-size: 14px;
}
.image-actions {
display: none;
}
.image-preview img {
transition: none;
}
.image-preview:hover img {
transform: none;
}
.image-name {
font-size: 10px;
padding: 2px 4px;
}
}
}
</style>