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