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,765 @@
<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>