766 lines
18 KiB
Vue
766 lines
18 KiB
Vue
<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>
|