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

766 lines
18 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-video-upload">
<div class="video-grid">
<div
v-if="fileFormModel.length > 0"
v-for="(item, index) in fileFormModel"
class="video-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
class="video-preview"
:class="{ playing: playingVideos[index] }"
@click="toggleVideoPlay(index)"
>
<video
:ref="setRef(`video-${index}`)"
:data-index="index"
:src="item"
:poster="getVideoPoster(item)"
preload="metadata"
loop
class="video-player"
@play="onVideoPlay(index)"
@pause="onVideoPause(index)"
@ended="onVideoEnded(index)"
>
您的浏览器不支持视频播放
</video>
<div class="video-overlay" :class="{ playing: playingVideos[index] }">
<play-circle-outlined v-if="!playingVideos[index]" class="play-icon" />
<pause-circle-outlined v-else class="play-icon" />
</div>
</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="acceptedVideoTypes"
: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 { useListRefs } from "@/iview/utils/useListRefs";
import {
DeleteOutlined,
PlusOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
} from "@ant-design/icons-vue";
const { setRef, getRef } = useListRefs();
const props = defineProps({
field: {
type: Object,
required: true,
},
accept: {
type: String,
default: "video/mp4,video/avi,video/mov,video/wmv,video/flv,video/webm",
},
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 playingVideos = ref({});
const supportedFormats = computed(() => {
return props.accept
.split(",")
.map((type) => type.replace("video/", "").toUpperCase())
.join("、");
});
const acceptedVideoTypes = computed(() => {
return props.accept || "video/mp4,video/avi,video/mov,video/wmv,video/flv,video/webm";
});
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 getVideoPoster = (url) => {
// 这里可以返回一个默认的封面图片或者视频的第一帧
return null;
};
// 视频播放控制
const toggleVideoPlay = (index) => {
const videoRef = getRef(`video-${index}`);
if (!videoRef) return;
if (videoRef.paused) {
videoRef.play();
} else {
videoRef.pause();
}
};
const onVideoPlay = (index) => {
playingVideos.value[index] = true;
};
const onVideoPause = (index) => {
playingVideos.value[index] = false;
};
const onVideoEnded = (index) => {
// 视频结束时自动重新播放(循环播放)
const videoRef = getRef(`video-${index}`);
if (videoRef && playingVideos.value[index]) {
videoRef.currentTime = 0;
videoRef.play();
}
};
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 = acceptedVideoTypes.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 fileToBase64(file);
const result = await apiRequest("api.system.global.file.upload", {
fileBase64: base64.split(",")[1],
type: "video",
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) {
// 检查文件类型
const isAcceptedType = acceptedVideoTypes.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 fileToBase64(file);
const result = await apiRequest("api.system.global.file.upload", {
fileBase64: base64.split(",")[1],
type: "video",
fileName: file.name,
});
return result;
} catch (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-video-upload {
width: 100%;
.video-grid {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.video-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;
background-color: #fafafa;
}
.video-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;
}
.video-item.drag-over {
border: 2px dashed #1890ff;
background-color: rgba(24, 144, 255, 0.05);
transform: scale(1.01);
}
.video-item:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.video-preview {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
border-radius: 8px;
cursor: pointer;
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
background-size: cover;
background-position: center;
}
.video-preview::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 1;
backdrop-filter: blur(2px);
transition: all 0.3s ease;
}
.video-preview.playing::before {
background: rgba(0, 0, 0, 0.1);
backdrop-filter: blur(0px);
}
.video-player {
width: 100%;
height: 100%;
object-fit: contain;
outline: none;
background-color: #000;
position: relative;
z-index: 2;
}
.video-player::-webkit-media-controls {
display: none !important;
}
.video-player::-webkit-media-controls-panel {
display: none !important;
}
.video-player::-webkit-media-controls-play-button {
display: none !important;
}
.video-player::-webkit-media-controls-timeline {
display: none !important;
}
.video-player::-webkit-media-controls-current-time-display {
display: none !important;
}
.video-player::-webkit-media-controls-time-remaining-display {
display: none !important;
}
.video-player::-webkit-media-controls-mute-button {
display: none !important;
}
.video-player::-webkit-media-controls-volume-slider {
display: none !important;
}
.video-player::-webkit-media-controls-fullscreen-button {
display: none !important;
}
.video-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: rgba(255, 255, 255, 0.8);
font-size: 24px;
pointer-events: none;
transition: all 0.3s ease;
z-index: 3;
}
.video-overlay.playing {
opacity: 0;
}
.video-overlay:hover {
opacity: 1;
}
.play-icon {
color: rgba(255, 255, 255, 0.9);
font-size: 40px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.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;
}
.video-preview:hover .video-player {
transform: scale(1.02);
}
.video-preview:hover .video-overlay {
opacity: 0.8;
}
.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 {
border-color: #1890ff;
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) {
.video-grid {
gap: 8px;
}
.video-item {
width: 120px;
height: 120px;
}
.upload-button {
width: 120px;
height: 120px;
}
.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;
}
.video-preview:hover .video-player {
transform: none;
}
}
}
</style>