|
|
@@ -0,0 +1,458 @@
|
|
|
+<template>
|
|
|
+ <div class="sa-upload-video">
|
|
|
+ <div class="upload-container sa-flex sa-flex-wrap">
|
|
|
+ <!-- 已上传的视频列表 -->
|
|
|
+ <div v-if="videoList.length > 0" class="sa-flex sa-flex-wrap">
|
|
|
+ <div v-for="(video, index) in videoList" :key="video" class="video-item mr-2px"
|
|
|
+ :style="{ width: size + 'px', height: size + 'px' }">
|
|
|
+ <div class="video-preview" :style="{ width: size + 'px', height: size + 'px' }">
|
|
|
+ <video :src="video" :style="{ width: '100%', height: '100%', objectFit: 'cover' }"
|
|
|
+ @click="previewVideo(video, index)" muted>
|
|
|
+ </video>
|
|
|
+ <div class="video-play-icon" @click="previewVideo(video, index)">
|
|
|
+ <el-icon size="24">
|
|
|
+ <VideoPlay />
|
|
|
+ </el-icon>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="video-mask">
|
|
|
+ <el-icon @click="previewVideo(video, index)" :title="t('common.preview')" size="24">
|
|
|
+ <ZoomIn />
|
|
|
+ </el-icon>
|
|
|
+ <el-icon @click="removeVideo(index)" :title="t('common.delete')" size="24">
|
|
|
+ <Delete />
|
|
|
+ </el-icon>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 上传按钮 -->
|
|
|
+ <div v-if="!maxCount || videoList.length < maxCount" class="upload-wrapper"
|
|
|
+ :style="{ width: size + 'px', height: size + 'px' }">
|
|
|
+ <div class="upload-trigger" :class="{ 'is-uploading': uploading }"
|
|
|
+ :style="{ width: size + 'px', height: size + 'px' }" @click="handleUploadClick">
|
|
|
+ <el-icon class="upload-icon" size="24">
|
|
|
+ <VideoCamera />
|
|
|
+ </el-icon>
|
|
|
+ <div class="upload-text">{{ placeholder || t('common.uploadVideo') }}</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 自定义loading遮罩 -->
|
|
|
+ <div v-if="uploading" class="upload-loading">
|
|
|
+ <div class="loading-spinner"></div>
|
|
|
+ <div class="loading-text">{{ t('common.uploading') }}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 隐藏的文件输入框 -->
|
|
|
+ <input ref="fileInputRef" type="file" :accept="acceptString" :multiple="multiple" style="display: none"
|
|
|
+ @change="handleFileSelect" />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 提示信息 -->
|
|
|
+ <div v-if="showTip" class="upload-tip">
|
|
|
+ <span v-if="maxCount">{{ t('common.maxUpload', { count: maxCount }) }},</span>
|
|
|
+ <span v-if="accept && accept.length">{{ t('common.supportFormats', { formats: accept.join('、') }) }},</span>
|
|
|
+ <span v-if="maxSize">{{ t('common.maxFileSize', { size: maxSize }) }}</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 视频预览弹窗 -->
|
|
|
+ <el-dialog v-model="previewVisible" title="视频预览" width="80%" :before-close="closePreview">
|
|
|
+ <div class="video-preview-container">
|
|
|
+ <video v-if="previewVideoUrl" :src="previewVideoUrl" controls style="width: 100%; height: auto;">
|
|
|
+ 您的浏览器不支持视频播放。
|
|
|
+ </video>
|
|
|
+ </div>
|
|
|
+ </el-dialog>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import { ref, computed, watch } from 'vue';
|
|
|
+import { ElMessage, ElDialog } from 'element-plus';
|
|
|
+import { VideoCamera, VideoPlay, ZoomIn, Delete } from '@element-plus/icons-vue';
|
|
|
+import adminApi from '@/app/admin/api';
|
|
|
+
|
|
|
+export default {
|
|
|
+ name: 'SaUploadVideo',
|
|
|
+ components: {
|
|
|
+ VideoCamera,
|
|
|
+ VideoPlay,
|
|
|
+ ZoomIn,
|
|
|
+ Delete,
|
|
|
+ ElDialog,
|
|
|
+ },
|
|
|
+};
|
|
|
+</script>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+import { useI18n } from 'vue-i18n';
|
|
|
+
|
|
|
+const { t } = useI18n();
|
|
|
+
|
|
|
+const props = defineProps({
|
|
|
+ modelValue: {
|
|
|
+ type: Array,
|
|
|
+ default: () => [],
|
|
|
+ },
|
|
|
+ // 是否直传,默认为true
|
|
|
+ directUpload: {
|
|
|
+ type: Boolean,
|
|
|
+ default: true,
|
|
|
+ },
|
|
|
+ // 最大上传数量
|
|
|
+ maxCount: {
|
|
|
+ type: Number,
|
|
|
+ default: 1,
|
|
|
+ },
|
|
|
+ // 支持的文件格式
|
|
|
+ accept: {
|
|
|
+ type: Array,
|
|
|
+ default: () => ['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm'],
|
|
|
+ },
|
|
|
+ // 最大文件大小(MB)
|
|
|
+ maxSize: {
|
|
|
+ type: Number,
|
|
|
+ default: 50,
|
|
|
+ },
|
|
|
+ // 是否支持多选
|
|
|
+ multiple: {
|
|
|
+ type: Boolean,
|
|
|
+ default: false,
|
|
|
+ },
|
|
|
+ // 视频预览尺寸
|
|
|
+ size: {
|
|
|
+ type: Number,
|
|
|
+ default: 100,
|
|
|
+ },
|
|
|
+ // 占位符文本
|
|
|
+ placeholder: {
|
|
|
+ type: String,
|
|
|
+ default: '',
|
|
|
+ },
|
|
|
+ // 是否显示提示信息
|
|
|
+ showTip: {
|
|
|
+ type: Boolean,
|
|
|
+ default: true,
|
|
|
+ },
|
|
|
+});
|
|
|
+
|
|
|
+const emit = defineEmits(['update:modelValue', 'change', 'upload-success', 'upload-error']);
|
|
|
+
|
|
|
+// 响应式数据
|
|
|
+const fileInputRef = ref();
|
|
|
+const uploading = ref(false);
|
|
|
+const previewVisible = ref(false);
|
|
|
+const previewVideoUrl = ref('');
|
|
|
+
|
|
|
+// 计算属性
|
|
|
+const videoList = computed({
|
|
|
+ get: () => {
|
|
|
+ const value = props.modelValue;
|
|
|
+ if (Array.isArray(value)) {
|
|
|
+ return value;
|
|
|
+ } else if (typeof value === 'string' && value) {
|
|
|
+ return [value];
|
|
|
+ }
|
|
|
+ return [];
|
|
|
+ },
|
|
|
+ set: (newValue) => {
|
|
|
+ if (props.maxCount === 1) {
|
|
|
+ emit('update:modelValue', newValue.length > 0 ? newValue[0] : '');
|
|
|
+ } else {
|
|
|
+ emit('update:modelValue', newValue);
|
|
|
+ }
|
|
|
+ emit('change', newValue);
|
|
|
+ },
|
|
|
+});
|
|
|
+
|
|
|
+const acceptString = computed(() => {
|
|
|
+ return props.accept.map(type => `.${type}`).join(',');
|
|
|
+});
|
|
|
+
|
|
|
+// 方法
|
|
|
+const handleUploadClick = () => {
|
|
|
+ if (uploading.value) return;
|
|
|
+ fileInputRef.value?.click();
|
|
|
+};
|
|
|
+
|
|
|
+const handleFileSelect = async (event) => {
|
|
|
+ const files = Array.from(event.target.files);
|
|
|
+ if (files.length === 0) return;
|
|
|
+
|
|
|
+ // 验证文件
|
|
|
+ for (const file of files) {
|
|
|
+ if (!validateFile(file)) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 上传文件
|
|
|
+ await uploadFiles(files);
|
|
|
+
|
|
|
+ // 清空input
|
|
|
+ event.target.value = '';
|
|
|
+};
|
|
|
+
|
|
|
+const validateFile = (file) => {
|
|
|
+ // 检查文件类型
|
|
|
+ const fileExtension = file.name.split('.').pop().toLowerCase();
|
|
|
+ if (!props.accept.includes(fileExtension)) {
|
|
|
+ ElMessage.error(`不支持的文件格式,请上传 ${props.accept.join('、')} 格式的视频`);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检查文件大小
|
|
|
+ const fileSizeMB = file.size / 1024 / 1024;
|
|
|
+ if (fileSizeMB > props.maxSize) {
|
|
|
+ ElMessage.error(`文件大小不能超过 ${props.maxSize}MB`);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检查数量限制
|
|
|
+ if (props.maxCount && videoList.value.length >= props.maxCount) {
|
|
|
+ ElMessage.error(`最多只能上传 ${props.maxCount} 个视频`);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ return true;
|
|
|
+};
|
|
|
+
|
|
|
+const uploadFiles = async (files) => {
|
|
|
+ uploading.value = true;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const uploadPromises = files.map(file => uploadSingleFile(file));
|
|
|
+ const results = await Promise.all(uploadPromises);
|
|
|
+
|
|
|
+ const successResults = results.filter(result => result.success);
|
|
|
+ if (successResults.length > 0) {
|
|
|
+ const newUrls = successResults.map(result => result.url);
|
|
|
+ videoList.value = [...videoList.value, ...newUrls];
|
|
|
+ emit('upload-success', newUrls);
|
|
|
+ ElMessage.success(`成功上传 ${successResults.length} 个视频`);
|
|
|
+ }
|
|
|
+
|
|
|
+ const failedResults = results.filter(result => !result.success);
|
|
|
+ if (failedResults.length > 0) {
|
|
|
+ emit('upload-error', failedResults);
|
|
|
+ ElMessage.error(`${failedResults.length} 个视频上传失败`);
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('上传视频失败:', error);
|
|
|
+ ElMessage.error('上传视频失败,请重试');
|
|
|
+ emit('upload-error', error);
|
|
|
+ } finally {
|
|
|
+ uploading.value = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const uploadSingleFile = async (file) => {
|
|
|
+ try {
|
|
|
+ const formData = new FormData();
|
|
|
+ formData.append('file', file);
|
|
|
+
|
|
|
+ const response = await adminApi.file.upload({ group: 'video', savelog: 1 }, formData);
|
|
|
+
|
|
|
+ if (response.data) {
|
|
|
+ return {
|
|
|
+ success: true,
|
|
|
+ url: response.data,
|
|
|
+ file: file,
|
|
|
+ };
|
|
|
+ } else {
|
|
|
+ return {
|
|
|
+ success: false,
|
|
|
+ error: '上传失败',
|
|
|
+ file: file,
|
|
|
+ };
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ return {
|
|
|
+ success: false,
|
|
|
+ error: error.message || '上传失败',
|
|
|
+ file: file,
|
|
|
+ };
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const removeVideo = (index) => {
|
|
|
+ const newList = [...videoList.value];
|
|
|
+ newList.splice(index, 1);
|
|
|
+ videoList.value = newList;
|
|
|
+};
|
|
|
+
|
|
|
+const previewVideo = (videoUrl, index) => {
|
|
|
+ previewVideoUrl.value = videoUrl;
|
|
|
+ previewVisible.value = true;
|
|
|
+};
|
|
|
+
|
|
|
+const closePreview = () => {
|
|
|
+ previewVisible.value = false;
|
|
|
+ previewVideoUrl.value = '';
|
|
|
+};
|
|
|
+
|
|
|
+// 监听props变化
|
|
|
+watch(() => props.modelValue, (newValue) => {
|
|
|
+ // 当外部值变化时,确保内部状态同步
|
|
|
+}, { immediate: true });
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+.sa-upload-video {
|
|
|
+ .upload-container {
|
|
|
+ gap: 8px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .video-item {
|
|
|
+ position: relative;
|
|
|
+ border: 1px solid var(--el-border-color);
|
|
|
+ border-radius: 6px;
|
|
|
+ overflow: hidden;
|
|
|
+ background: #fafafa;
|
|
|
+
|
|
|
+ .video-preview {
|
|
|
+ position: relative;
|
|
|
+
|
|
|
+ video {
|
|
|
+ border-radius: 6px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .video-play-icon {
|
|
|
+ position: absolute;
|
|
|
+ top: 50%;
|
|
|
+ left: 50%;
|
|
|
+ transform: translate(-50%, -50%);
|
|
|
+ background: rgba(0, 0, 0, 0.6);
|
|
|
+ color: white;
|
|
|
+ border-radius: 50%;
|
|
|
+ width: 40px;
|
|
|
+ height: 40px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: all 0.3s;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ background: rgba(0, 0, 0, 0.8);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .video-mask {
|
|
|
+ position: absolute;
|
|
|
+ top: 0;
|
|
|
+ left: 0;
|
|
|
+ right: 0;
|
|
|
+ bottom: 0;
|
|
|
+ background: rgba(0, 0, 0, 0.5);
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ gap: 8px;
|
|
|
+ opacity: 0;
|
|
|
+ transition: opacity 0.3s;
|
|
|
+
|
|
|
+ .el-icon {
|
|
|
+ color: white;
|
|
|
+ cursor: pointer;
|
|
|
+ padding: 4px;
|
|
|
+ border-radius: 4px;
|
|
|
+ transition: background-color 0.3s;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ background-color: rgba(255, 255, 255, 0.2);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &:hover .video-mask {
|
|
|
+ opacity: 1;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .upload-wrapper {
|
|
|
+ position: relative;
|
|
|
+
|
|
|
+ .upload-trigger {
|
|
|
+ border: 2px dashed var(--el-border-color);
|
|
|
+ border-radius: 6px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: all 0.3s;
|
|
|
+ background: #fafafa;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ border-color: var(--el-color-primary);
|
|
|
+ background: var(--el-color-primary-light-9);
|
|
|
+ }
|
|
|
+
|
|
|
+ &.is-uploading {
|
|
|
+ cursor: not-allowed;
|
|
|
+ opacity: 0.6;
|
|
|
+ }
|
|
|
+
|
|
|
+ .upload-icon {
|
|
|
+ color: var(--el-text-color-secondary);
|
|
|
+ margin-bottom: 8px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .upload-text {
|
|
|
+ font-size: 12px;
|
|
|
+ color: var(--el-text-color-regular);
|
|
|
+ text-align: center;
|
|
|
+ line-height: 1.2;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .upload-loading {
|
|
|
+ position: absolute;
|
|
|
+ top: 0;
|
|
|
+ left: 0;
|
|
|
+ right: 0;
|
|
|
+ bottom: 0;
|
|
|
+ background: rgba(255, 255, 255, 0.9);
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ border-radius: 6px;
|
|
|
+
|
|
|
+ .loading-spinner {
|
|
|
+ width: 20px;
|
|
|
+ height: 20px;
|
|
|
+ border: 2px solid var(--el-color-primary-light-8);
|
|
|
+ border-top: 2px solid var(--el-color-primary);
|
|
|
+ border-radius: 50%;
|
|
|
+ animation: spin 1s linear infinite;
|
|
|
+ margin-bottom: 8px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .loading-text {
|
|
|
+ font-size: 12px;
|
|
|
+ color: var(--el-text-color-regular);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .upload-tip {
|
|
|
+ margin-top: 8px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: var(--el-text-color-secondary);
|
|
|
+ line-height: 1.4;
|
|
|
+ }
|
|
|
+
|
|
|
+ .video-preview-container {
|
|
|
+ text-align: center;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+@keyframes spin {
|
|
|
+ 0% { transform: rotate(0deg); }
|
|
|
+ 100% { transform: rotate(360deg); }
|
|
|
+}
|
|
|
+</style>
|