1689 lines
43 KiB
Vue
1689 lines
43 KiB
Vue
<script setup>
|
||
// 导入所需的API和组件
|
||
import { uploadImg } from "@/api-norm/common/index.js";
|
||
import { ref, shallowRef, watch, onMounted, computed, useAttrs, useId } from "vue";
|
||
import { previewImage } from "@/components/x-naive-ui/x-preview-img/index.ts";
|
||
import { isEqual } from "lodash-es";
|
||
import { useMessage } from "naive-ui";
|
||
|
||
/**
|
||
* 文件类型配置对象
|
||
* 定义了不同文件类型的正则匹配模式和默认封面
|
||
*/
|
||
const FileTypes = {
|
||
IMAGE: {
|
||
pattern: /\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i,
|
||
defaultCover: ""
|
||
},
|
||
VIDEO: {
|
||
pattern: /\.(mp4|webm|ogg|avi|mov|mkv|flv|wmv)$/i,
|
||
defaultCover: new URL(`@/icons/video.svg`, import.meta.url).href
|
||
},
|
||
PDF: {
|
||
pattern: /\.pdf$/i,
|
||
defaultCover: new URL("@/icons/Pdf.svg", import.meta.url).href
|
||
}
|
||
};
|
||
|
||
// 组件属性定义
|
||
const props = defineProps({
|
||
// 视频封面字段名
|
||
fieldCover: {
|
||
type: String,
|
||
default: "coverUrl"
|
||
},
|
||
// 视频URL字段名
|
||
fieldVideo: {
|
||
type: String,
|
||
default: "videoUrl"
|
||
},
|
||
// 值格式:array或simple
|
||
valueFormat: {
|
||
type: String,
|
||
default: "array"
|
||
},
|
||
// 上传项宽度
|
||
itemWidth: {
|
||
type: [String, Number],
|
||
default: "120px"
|
||
},
|
||
// 上传项高度
|
||
itemHeight: {
|
||
type: [String, Number],
|
||
default: "120px"
|
||
},
|
||
// 组件值
|
||
value: {
|
||
type: [String, Array],
|
||
default: () => []
|
||
},
|
||
// 最大重试次数
|
||
maxRetries: {
|
||
type: Number,
|
||
default: 3
|
||
},
|
||
// 最大上传数量
|
||
max: {
|
||
type: Number,
|
||
default: 9
|
||
},
|
||
// 是否允许多文件选择
|
||
multiple: {
|
||
type: Boolean,
|
||
default: false
|
||
},
|
||
// 是否禁用
|
||
disabled: {
|
||
type: Boolean,
|
||
default: false
|
||
},
|
||
// 最大文件大小(B)
|
||
maxSize: {
|
||
type: Number,
|
||
default: 0
|
||
},
|
||
// 接受的文件类型
|
||
accept: {
|
||
type: String,
|
||
default: ""
|
||
},
|
||
// 是否显示下载按钮
|
||
showDownload: {
|
||
type: Boolean,
|
||
default: false
|
||
},
|
||
// 是否显示删除按钮
|
||
showDelete: {
|
||
type: Boolean,
|
||
default: true
|
||
}
|
||
});
|
||
|
||
// 定义事件
|
||
const emit = defineEmits([
|
||
'update:value',
|
||
'before-upload',
|
||
'upload-error',
|
||
'change',
|
||
'download',
|
||
'preview',
|
||
'remove'
|
||
]);
|
||
|
||
// 组件状态变量
|
||
const isUploading = ref(false);
|
||
const message = useMessage();
|
||
const attrs = useAttrs();
|
||
const fileCache = new WeakMap();
|
||
const fileList = shallowRef([]);
|
||
const showPreview = ref(false);
|
||
const previewVideoUrl = ref("");
|
||
const previousValue = ref();
|
||
const retryingFiles = shallowRef({});
|
||
const inputFileRef = ref(null);
|
||
const dragActive = ref(false);
|
||
const instanceId = useId();
|
||
|
||
/**
|
||
* 通用文件处理工具
|
||
* 提供文件类型检测和处理功能
|
||
*/
|
||
const FileUtils = {
|
||
/**
|
||
* 检查文件URL是否符合指定类型
|
||
* @param {string} url - 文件URL
|
||
* @param {string} type - 文件类型 (IMAGE, VIDEO, PDF)
|
||
* @returns {boolean} - 是否符合类型
|
||
*/
|
||
isFileType(url, type) {
|
||
if (!url) return false;
|
||
return FileTypes[type].pattern.test(url.toLowerCase ? url.toLowerCase() : "");
|
||
},
|
||
|
||
/**
|
||
* 获取文件对象的类型
|
||
* @param {Object} file - 文件对象
|
||
* @returns {string} - 文件类型 (video, image, pdf)
|
||
*/
|
||
getFileType(file) {
|
||
if (!file.name && !file.url) return "image";
|
||
|
||
const testPath = file.name || file.url;
|
||
|
||
// 如果缓存中有该文件的类型,直接返回
|
||
if (fileCache.has(file)) {
|
||
return fileCache.get(file);
|
||
}
|
||
|
||
// 根据文件路径判断类型
|
||
let type = "image";
|
||
|
||
if (this.isFileType(testPath, "VIDEO")) {
|
||
type = "video";
|
||
} else if (this.isFileType(testPath, "PDF")) {
|
||
type = "pdf";
|
||
}
|
||
|
||
// 缓存文件类型
|
||
fileCache.set(file, type);
|
||
return type;
|
||
},
|
||
|
||
/**
|
||
* 创建文件对象
|
||
* @param {string} url - 文件URL
|
||
* @param {string|null} name - 文件名称
|
||
* @returns {Object} - 文件对象
|
||
*/
|
||
createFileObject(url, name = null) {
|
||
return {
|
||
url,
|
||
name,
|
||
status: "finished",
|
||
id: Math.random().toString(36).substring(2, 10)
|
||
};
|
||
},
|
||
|
||
/**
|
||
* 处理文件项
|
||
* @param {Object|string} item - 文件项
|
||
* @param {string} fileType - 文件类型 (video, image, pdf)
|
||
* @returns {Object|null} - 处理后的文件对象
|
||
*/
|
||
processFileItem(item, fileType) {
|
||
// 处理视频文件
|
||
if (fileType === "video") {
|
||
return this.processVideoFile(item);
|
||
}
|
||
|
||
// 处理图片或PDF文件
|
||
return this.processImageOrPdf(item);
|
||
},
|
||
|
||
/**
|
||
* 处理视频文件
|
||
* @param {Object|string} item - 视频项
|
||
* @returns {Object|null} - 处理后的文件对象
|
||
*/
|
||
processVideoFile(item) {
|
||
if (typeof item === "object") {
|
||
return this.createFileObject(
|
||
item[props.fieldCover] || FileTypes.VIDEO.defaultCover,
|
||
item[props.fieldVideo]
|
||
);
|
||
}
|
||
|
||
if (typeof item === "string" && isVideo(item)) {
|
||
return this.createFileObject(FileTypes.VIDEO.defaultCover, item);
|
||
}
|
||
|
||
return null;
|
||
},
|
||
|
||
/**
|
||
* 处理图片或PDF文件
|
||
* @param {string} value - 文件URL
|
||
* @returns {Object|null} - 处理后的文件对象
|
||
*/
|
||
processImageOrPdf(value) {
|
||
if (isPDF(value)) {
|
||
return this.createFileObject(FileTypes.PDF.defaultCover, value);
|
||
}
|
||
|
||
if (isImage(value)) {
|
||
return this.createFileObject(value);
|
||
}
|
||
|
||
return null;
|
||
},
|
||
|
||
/**
|
||
* 处理文件列表
|
||
* @param {Array|Object|string} value - 文件或列表
|
||
* @param {string} fileType - 文件类型 (video, image, pdf)
|
||
* @returns {Array} - 处理后的文件对象数组
|
||
*/
|
||
processFileList(value, fileType) {
|
||
// 如果值为空,返回空数组
|
||
if (!value) return [];
|
||
|
||
// 如果不是数组,处理单个文件项
|
||
if (!Array.isArray(value)) {
|
||
const result = this.processFileItem(value, fileType);
|
||
return result ? [result] : [];
|
||
}
|
||
|
||
// 处理数组中的每个文件项
|
||
const results = [];
|
||
for (let i = 0; i < value.length; i++) {
|
||
const result = this.processFileItem(value[i], fileType);
|
||
if (result !== null) {
|
||
results.push(result);
|
||
}
|
||
}
|
||
|
||
return results;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 检查文件URL是否符合图片类型
|
||
* @param {string} url - 文件URL
|
||
* @returns {boolean} - 是否为图片
|
||
*/
|
||
function isImage(url) {
|
||
return FileUtils.isFileType(url, "IMAGE");
|
||
}
|
||
|
||
/**
|
||
* 检查文件URL是否符合视频类型
|
||
* @param {string} url - 文件URL
|
||
* @returns {boolean} - 是否为视频
|
||
*/
|
||
function isVideo(url) {
|
||
return FileUtils.isFileType(url, "VIDEO");
|
||
}
|
||
|
||
/**
|
||
* 检查文件URL是否符合PDF类型
|
||
* @param {string} url - 文件URL
|
||
* @returns {boolean} - 是否为PDF
|
||
*/
|
||
function isPDF(url) {
|
||
return FileUtils.isFileType(url, "PDF");
|
||
}
|
||
|
||
/**
|
||
* 获取文件对象的类型
|
||
* @param {Object} file - 文件对象
|
||
* @returns {string} - 文件类型
|
||
*/
|
||
function getFileType(file) {
|
||
// 如果已经缓存过类型,直接返回
|
||
if (fileCache.has(file)) {
|
||
return fileCache.get(file);
|
||
}
|
||
|
||
const testPath = file.name || file.url;
|
||
if (!testPath) return "image";
|
||
|
||
let type = "image";
|
||
|
||
// 使用一次循环处理所有类型检测
|
||
const lowerPath = testPath.toLowerCase();
|
||
|
||
// 按优先级检查类型(先检查更常见的类型)
|
||
if (FileTypes.IMAGE.pattern.test(lowerPath)) {
|
||
type = "image";
|
||
} else if (FileTypes.PDF.pattern.test(lowerPath)) {
|
||
type = "pdf";
|
||
} else if (FileTypes.VIDEO.pattern.test(lowerPath)) {
|
||
type = "video";
|
||
}
|
||
|
||
// 缓存并返回结果
|
||
fileCache.set(file, type);
|
||
return type;
|
||
}
|
||
|
||
/**
|
||
* 处理视频文件列表
|
||
* @param {Array|Object} value - 视频文件或列表
|
||
* @returns {Array} - 处理后的文件对象数组
|
||
*/
|
||
function handleVideoFiles(value) {
|
||
return FileUtils.processFileList(value, "video");
|
||
}
|
||
|
||
/**
|
||
* 处理图片和PDF文件列表
|
||
* @param {string|Array} value - 文件URL或列表
|
||
* @returns {Array} - 处理后的文件对象数组
|
||
*/
|
||
function handleImageAndPdf(value) {
|
||
// 如果值为空,返回空数组
|
||
if (!value) return [];
|
||
|
||
// 如果是字符串,处理单个文件
|
||
if (typeof value === "string") {
|
||
if (isPDF(value)) {
|
||
return [FileUtils.createFileObject(FileTypes.PDF.defaultCover, value)];
|
||
}
|
||
|
||
if (isImage(value)) {
|
||
return [FileUtils.createFileObject(value)];
|
||
}
|
||
|
||
return [];
|
||
}
|
||
|
||
// 处理数组中的每个文件
|
||
const results = [];
|
||
for (let i = 0; i < value.length; i++) {
|
||
const item = value[i];
|
||
if (isPDF(item)) {
|
||
results.push(FileUtils.createFileObject(FileTypes.PDF.defaultCover, item));
|
||
} else if (isImage(item)) {
|
||
results.push(FileUtils.createFileObject(item));
|
||
}
|
||
}
|
||
|
||
return results;
|
||
}
|
||
|
||
/**
|
||
* 上传处理器对象
|
||
* 处理文件上传相关的所有操作
|
||
*/
|
||
const UploadHandler = {
|
||
/**
|
||
* 处理文件上传
|
||
* @param {File} file - 文件对象
|
||
* @returns {Promise<void>}
|
||
*/
|
||
async uploadFile(file) {
|
||
try {
|
||
console.log("开始上传文件:", file.name);
|
||
|
||
// 创建内部文件对象
|
||
const fileObj = {
|
||
id: Math.random().toString(36).substring(2, 10),
|
||
name: file.name,
|
||
size: file.size,
|
||
type: file.type,
|
||
status: "uploading",
|
||
file: file,
|
||
percentage: 0
|
||
};
|
||
|
||
// 添加到文件列表
|
||
fileList.value = [...fileList.value, fileObj];
|
||
|
||
// 开始上传
|
||
isUploading.value = true;
|
||
|
||
// 初始化重试计数
|
||
retryingFiles.value = {
|
||
...retryingFiles.value,
|
||
[fileObj.id]: { count: 0, status: 'uploading' }
|
||
};
|
||
|
||
// 调用上传API
|
||
await this.processUpload(fileObj);
|
||
|
||
} catch (error) {
|
||
console.error("Upload error:", error);
|
||
message.error("上传失败,请重试");
|
||
} finally {
|
||
isUploading.value = false;
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 处理上传过程
|
||
* @param {Object} fileObj - 文件对象
|
||
* @returns {Promise<void>}
|
||
*/
|
||
async processUpload(fileObj) {
|
||
try {
|
||
console.log("处理上传过程:", fileObj.name);
|
||
|
||
// 创建表单数据
|
||
const formData = new FormData();
|
||
formData.append("file", fileObj.file);
|
||
formData.append("source", "artwork");
|
||
|
||
if (fileObj.type && fileObj.type.includes("video")) {
|
||
formData.append("type", "video");
|
||
}
|
||
|
||
console.log("表单数据准备完成,开始调用上传API");
|
||
|
||
// 上传文件
|
||
const response = await uploadImg(formData, {
|
||
onUploadProgress: (progressEvent) => {
|
||
const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||
console.log(`上传进度: ${percent}%`);
|
||
this.updateFileProgress(fileObj.id, percent);
|
||
}
|
||
});
|
||
|
||
console.log("上传API响应:", response);
|
||
|
||
// 处理响应
|
||
if (response.status !== 0) {
|
||
throw new Error("Upload failed with status: " + response.status);
|
||
}
|
||
|
||
// 上传成功处理
|
||
this.handleUploadSuccess(fileObj, response.data);
|
||
|
||
} catch (error) {
|
||
console.error("处理上传过程出错:", error);
|
||
// 处理上传错误
|
||
await this.handleUploadError(error, fileObj);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 更新上传进度
|
||
* @param {string} fileId - 文件ID
|
||
* @param {number} percent - 进度百分比
|
||
*/
|
||
updateFileProgress(fileId, percent) {
|
||
fileList.value = fileList.value.map(item => {
|
||
if (item.id === fileId) {
|
||
return { ...item, percentage: percent };
|
||
}
|
||
return item;
|
||
});
|
||
|
||
// 触发change事件
|
||
emit('change', { fileList: fileList.value });
|
||
},
|
||
|
||
/**
|
||
* 处理上传成功
|
||
* @param {Object} fileObj - 文件对象
|
||
* @param {Object} data - 响应数据
|
||
*/
|
||
handleUploadSuccess(fileObj, data) {
|
||
// 更新文件状态
|
||
fileList.value = fileList.value.map(item => {
|
||
if (item.id === fileObj.id) {
|
||
const updatedItem = { ...item, status: "finished", percentage: 100 };
|
||
|
||
// 处理视频文件
|
||
if (item.type && item.type.includes("video")) {
|
||
updatedItem.url = data.cover_url || FileTypes.VIDEO.defaultCover;
|
||
updatedItem.name = data.ori_url;
|
||
|
||
// 设置封面图片的扩展名
|
||
let extension = "jpg";
|
||
if (data.cover_url) {
|
||
const parts = data.cover_url.split(".");
|
||
if (parts.length > 1) {
|
||
const lastPart = parts[parts.length - 1];
|
||
extension = lastPart.split("?")[0] || "jpg";
|
||
}
|
||
}
|
||
|
||
updatedItem.type = `image/${extension}`;
|
||
}
|
||
// 处理PDF文件
|
||
else if (item.type && item.type.includes("pdf")) {
|
||
updatedItem.url = FileTypes.PDF.defaultCover;
|
||
updatedItem.name = data.ori_url;
|
||
updatedItem.type = "image/png";
|
||
}
|
||
// 处理图片文件
|
||
else {
|
||
updatedItem.url = data.ori_url;
|
||
}
|
||
|
||
return updatedItem;
|
||
}
|
||
return item;
|
||
});
|
||
|
||
// 清除重试状态
|
||
if (retryingFiles.value[fileObj.id]) {
|
||
const { count } = retryingFiles.value[fileObj.id];
|
||
retryingFiles.value = {
|
||
...retryingFiles.value,
|
||
[fileObj.id]: { count, status: 'success' }
|
||
};
|
||
|
||
// 如果之前有重试,显示成功消息
|
||
if (count > 0) {
|
||
message.success(`文件 "${fileObj.name}" 上传成功 (重试 ${count} 次后)`);
|
||
}
|
||
}
|
||
|
||
// 更新组件值
|
||
updateFileList();
|
||
message.success("上传成功");
|
||
emit('change', { fileList: fileList.value });
|
||
},
|
||
|
||
/**
|
||
* 处理上传错误
|
||
* @param {Error} error - 错误对象
|
||
* @param {Object} fileObj - 文件对象
|
||
* @returns {Promise<void>}
|
||
*/
|
||
async handleUploadError(error, fileObj) {
|
||
const fileInfo = retryingFiles.value[fileObj.id] || { count: 0, status: 'error' };
|
||
|
||
// 检查是否可以重试
|
||
if (fileInfo.count < props.maxRetries) {
|
||
// 更新重试计数
|
||
retryingFiles.value = {
|
||
...retryingFiles.value,
|
||
[fileObj.id]: {
|
||
count: fileInfo.count + 1,
|
||
status: 'retrying'
|
||
}
|
||
};
|
||
|
||
// 更新文件状态为重试中
|
||
fileList.value = fileList.value.map(item => {
|
||
if (item.id === fileObj.id) {
|
||
return { ...item, status: "retrying", percentage: 0 };
|
||
}
|
||
return item;
|
||
});
|
||
|
||
// 显示重试消息
|
||
message.info(`正在重试上传 "${fileObj.name}" (${fileInfo.count + 1}/${props.maxRetries})`);
|
||
|
||
// 延迟一秒后重试
|
||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||
|
||
try {
|
||
// 重新上传
|
||
await this.processUpload(fileObj);
|
||
return;
|
||
} catch (retryError) {
|
||
console.error("Retry failed:", retryError);
|
||
// 如果重试失败,继续处理错误
|
||
}
|
||
}
|
||
|
||
// 如果重试次数已用完或重试失败,标记为失败
|
||
retryingFiles.value = {
|
||
...retryingFiles.value,
|
||
[fileObj.id]: {
|
||
count: fileInfo.count,
|
||
status: 'failed'
|
||
}
|
||
};
|
||
|
||
// 更新文件状态为失败
|
||
fileList.value = fileList.value.map(item => {
|
||
if (item.id === fileObj.id) {
|
||
return { ...item, status: "error", percentage: 0 };
|
||
}
|
||
return item;
|
||
});
|
||
|
||
// 根据错误类型显示不同的错误消息
|
||
let errorMessage = "上传失败";
|
||
|
||
if (error.name === 'NetworkError' || error.message?.includes('network')) {
|
||
errorMessage = "网络错误,请检查您的网络连接";
|
||
} else if (error.name === 'TimeoutError' || error.message?.includes('timeout')) {
|
||
errorMessage = "上传超时,服务器响应时间过长";
|
||
} else if (error.response?.status === 413) {
|
||
errorMessage = "文件太大,超出服务器接受的大小限制";
|
||
} else if (error.response?.status === 403) {
|
||
errorMessage = "没有权限上传文件";
|
||
} else if (error.response?.status >= 500) {
|
||
errorMessage = "服务器错误,请稍后重试";
|
||
}
|
||
|
||
// 显示错误消息
|
||
message.error(`${fileObj.name}: ${errorMessage}`);
|
||
|
||
// 触发错误事件
|
||
emit('upload-error', {
|
||
file: fileObj,
|
||
error,
|
||
message: errorMessage,
|
||
retries: fileInfo.count
|
||
});
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 验证文件类型
|
||
* @param {File} file - 文件对象
|
||
* @returns {boolean} - 是否有效
|
||
*/
|
||
function validateFileType(file) {
|
||
const accept = props.accept;
|
||
if (!accept) return true;
|
||
|
||
// 将accept字符串转换为数组并规范化
|
||
const acceptedTypes = [];
|
||
const acceptParts = accept.split(',');
|
||
|
||
for (let i = 0; i < acceptParts.length; i++) {
|
||
let type = acceptParts[i].trim().toLowerCase();
|
||
|
||
// 处理image/*这样的通配符格式
|
||
if (type.endsWith('/*')) {
|
||
type = type.split('/')[0];
|
||
}
|
||
|
||
// 处理.jpg, .pdf这样的具体格式
|
||
if (type.startsWith('.')) {
|
||
type = type.substring(1);
|
||
}
|
||
|
||
acceptedTypes.push(type);
|
||
}
|
||
|
||
// 获取文件类型和扩展名
|
||
const fileType = file.type.toLowerCase().split('/')[0];
|
||
const fileNameParts = file.name.split('.');
|
||
const fileExtension = fileNameParts.length > 1 ? fileNameParts.pop().toLowerCase() : '';
|
||
|
||
// 检查文件是否符合接受的类型
|
||
for (let i = 0; i < acceptedTypes.length; i++) {
|
||
const type = acceptedTypes[i];
|
||
|
||
// 检查文件MIME类型
|
||
if (fileType === type) return true;
|
||
|
||
// 检查文件扩展名
|
||
if (fileExtension === type) return true;
|
||
|
||
// 检查完整的MIME类型
|
||
if (file.type.toLowerCase() === type) return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 验证文件大小
|
||
* @param {File} file - 文件对象
|
||
* @returns {boolean} - 是否有效
|
||
*/
|
||
function validateFileSize(file) {
|
||
if (props.maxSize <= 0) return true;
|
||
return file.size <= props.maxSize;
|
||
}
|
||
|
||
/**
|
||
* 验证上传前的文件
|
||
* @param {File} file - 文件对象
|
||
* @returns {Promise<boolean>} - 是否允许上传
|
||
*/
|
||
async function validateBeforeUpload(file) {
|
||
// 验证文件类型
|
||
if (!validateFileType(file)) {
|
||
message.error(`只能上传${props.accept}类型的文件`);
|
||
return false;
|
||
}
|
||
|
||
// 验证文件大小
|
||
if (!validateFileSize(file)) {
|
||
const sizeInMB = props.maxSize / (1024 * 1024);
|
||
message.error(`文件大小不能超过${sizeInMB.toFixed(2)}MB`);
|
||
return false;
|
||
}
|
||
|
||
// 如果父组件定义了before-upload事件,则执行父组件的验证
|
||
try {
|
||
// 触发父组件的验证函数
|
||
const result = emit('before-upload', { file });
|
||
// emit不会返回值,所以我们不依赖结果,除非明确返回false
|
||
return result !== false ? true : false;
|
||
} catch (error) {
|
||
console.error('执行父组件验证时出错:', error);
|
||
return true; // 出错也允许上传
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 计算样式
|
||
* 缓存计算结果以提高性能
|
||
*/
|
||
const memoizedComputedStyles = computed(() => {
|
||
/**
|
||
* 为数值添加px单位(如果需要)
|
||
* @param {string|number} value - 样式值
|
||
* @returns {string} - 处理后的样式值
|
||
*/
|
||
function addPxIfNeeded(value) {
|
||
const strValue = String(value).trim();
|
||
|
||
// 如果不包含单位,添加px
|
||
if (!/\d+(px|em|rem|%)$/.test(strValue)) {
|
||
return `${strValue}px`;
|
||
}
|
||
|
||
return strValue;
|
||
}
|
||
|
||
// 解析尺寸为数字(去掉单位)
|
||
function parseSizeToNumber(size) {
|
||
if (typeof size === 'number') return size;
|
||
return parseInt(String(size).replace(/[^0-9]/g, ''));
|
||
}
|
||
|
||
const width = addPxIfNeeded(props.itemWidth);
|
||
const height = addPxIfNeeded(props.itemHeight);
|
||
|
||
// 根据容器尺寸计算加号大小、边框样式和操作按钮尺寸
|
||
let plusSize = '24px';
|
||
let borderStyle = '1px dashed #d9d9d9';
|
||
let actionButtonSize = '32px';
|
||
let actionIconSize = '16px';
|
||
let progressFontSize = '12px';
|
||
let progressBarHeight = '6px';
|
||
|
||
const numWidth = parseSizeToNumber(props.itemWidth);
|
||
|
||
if (numWidth <= 30) {
|
||
plusSize = '12px';
|
||
borderStyle = '1px solid #d9d9d9';
|
||
actionButtonSize = '16px';
|
||
actionIconSize = '10px';
|
||
progressFontSize = '8px';
|
||
progressBarHeight = '3px';
|
||
} else if (numWidth <= 50) {
|
||
plusSize = '16px';
|
||
borderStyle = '1px solid #d9d9d9';
|
||
actionButtonSize = '20px';
|
||
actionIconSize = '12px';
|
||
progressFontSize = '9px';
|
||
progressBarHeight = '4px';
|
||
} else if (numWidth <= 80) {
|
||
plusSize = '20px';
|
||
actionButtonSize = '24px';
|
||
actionIconSize = '14px';
|
||
progressFontSize = '10px';
|
||
progressBarHeight = '5px';
|
||
}
|
||
|
||
return {
|
||
fileWidth: width,
|
||
fileHeight: height,
|
||
uploadButton: {
|
||
width: width,
|
||
height: height,
|
||
border: borderStyle
|
||
},
|
||
plusIconSize: plusSize,
|
||
actionButtonSize: actionButtonSize,
|
||
actionIconSize: actionIconSize,
|
||
progressFontSize: progressFontSize,
|
||
progressBarHeight: progressBarHeight
|
||
};
|
||
});
|
||
|
||
/**
|
||
* 更新文件列表并发出更新事件
|
||
* 优化性能,减少循环次数
|
||
*/
|
||
function updateFileList() {
|
||
const updatedFiles = [];
|
||
let hasVideo = false;
|
||
|
||
// 处理所有文件(单循环处理所有文件)
|
||
for (let i = 0; i < fileList.value.length; i++) {
|
||
const file = fileList.value[i];
|
||
if (file.status !== "finished") continue;
|
||
|
||
const type = getFileType(file);
|
||
|
||
// 处理视频文件
|
||
if (type === "video") {
|
||
hasVideo = true;
|
||
if (file.url === FileTypes.VIDEO.defaultCover) {
|
||
updatedFiles.push(file.name);
|
||
} else {
|
||
const result = {
|
||
[props.fieldVideo]: file.name,
|
||
[props.fieldCover]: file.url
|
||
};
|
||
updatedFiles.push(result);
|
||
}
|
||
}
|
||
// 处理PDF文件或图片文件
|
||
else if (type === "pdf") {
|
||
updatedFiles.push(file.name);
|
||
}
|
||
// 默认为图片文件
|
||
else {
|
||
updatedFiles.push(file.url);
|
||
}
|
||
}
|
||
|
||
// 处理输出格式
|
||
let updatedValue = updatedFiles;
|
||
|
||
// 如果没有视频且格式为simple,尝试使用简单格式
|
||
if (!hasVideo && props.valueFormat === "simple") {
|
||
// 检查是否所有项都是字符串类型
|
||
const isAllString = updatedFiles.every(item => typeof item === "string");
|
||
|
||
if (isAllString) {
|
||
updatedValue = updatedFiles.join(",");
|
||
}
|
||
}
|
||
|
||
// 发出更新事件
|
||
emit("update:value", updatedValue);
|
||
}
|
||
|
||
/**
|
||
* 初始化文件列表
|
||
* 根据props.value设置fileList
|
||
*/
|
||
function initFileList() {
|
||
const newValue = props.value;
|
||
|
||
// 如果值为空,清空文件列表
|
||
if (!newValue || (Array.isArray(newValue) && !newValue.length)) {
|
||
fileList.value = [];
|
||
return;
|
||
}
|
||
|
||
/**
|
||
* 检查当前值与新值是否相等
|
||
* @returns {boolean} - 是否相等
|
||
*/
|
||
function isValueEqual() {
|
||
// 获取当前文件列表的值
|
||
const currentValue = [];
|
||
|
||
for (let i = 0; i < fileList.value.length; i++) {
|
||
const file = fileList.value[i];
|
||
if (file.status !== "finished") continue;
|
||
|
||
const type = getFileType(file);
|
||
|
||
// 处理视频文件
|
||
if (type === "video") {
|
||
if (file.url === FileTypes.VIDEO.defaultCover) {
|
||
currentValue.push(file.name);
|
||
} else {
|
||
const result = {};
|
||
result[props.fieldVideo] = file.name;
|
||
result[props.fieldCover] = file.url;
|
||
currentValue.push(result);
|
||
}
|
||
} else {
|
||
// 处理其他文件
|
||
currentValue.push(type === "pdf" ? file.name : file.url);
|
||
}
|
||
}
|
||
|
||
// 将新值转换为数组进行比较
|
||
const compareValue = Array.isArray(newValue) ? newValue : [newValue];
|
||
|
||
// 长度不同,直接返回false
|
||
if (currentValue.length !== compareValue.length) return false;
|
||
|
||
// 逐项比较
|
||
for (let i = 0; i < compareValue.length; i++) {
|
||
const item = compareValue[i];
|
||
const curr = currentValue[i];
|
||
|
||
// 如果都是对象,比较对象的属性
|
||
if (typeof item === "object" && typeof curr === "object") {
|
||
if (item[props.fieldVideo] !== curr[props.fieldVideo] ||
|
||
item[props.fieldCover] !== curr[props.fieldCover]) {
|
||
return false;
|
||
}
|
||
}
|
||
// 否则直接比较值
|
||
else if (item !== curr) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
// 如果值相等,不需要更新
|
||
if (isValueEqual()) return;
|
||
|
||
/**
|
||
* 根据accept属性处理文件
|
||
* @returns {Array} - 处理后的文件列表
|
||
*/
|
||
function processFilesByAccept() {
|
||
// 获取accept属性
|
||
const acceptTypes = props.accept ? String(props.accept).split(",") : null;
|
||
|
||
// 如果没有accept限制
|
||
if (!acceptTypes) {
|
||
// 将值转换为数组
|
||
const items = Array.isArray(newValue) ? newValue : [newValue];
|
||
|
||
// 分离视频和其他文件
|
||
const videos = [];
|
||
const others = [];
|
||
|
||
for (let i = 0; i < items.length; i++) {
|
||
const item = items[i];
|
||
if ((typeof item === "object" && item[props.fieldVideo]) ||
|
||
(typeof item === "string" && isVideo(item))) {
|
||
videos.push(item);
|
||
} else if (typeof item === "string" && !isVideo(item)) {
|
||
others.push(item);
|
||
}
|
||
}
|
||
|
||
// 合并处理结果
|
||
return [
|
||
...handleVideoFiles(videos),
|
||
...handleImageAndPdf(others)
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 检查是否接受指定类型
|
||
* @param {string} type - 文件类型
|
||
* @returns {boolean} - 是否接受
|
||
*/
|
||
function isAcceptedType(type) {
|
||
for (let i = 0; i < acceptTypes.length; i++) {
|
||
if (acceptTypes[i].includes(type)) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// 根据accept类型处理文件
|
||
if (isAcceptedType("video")) {
|
||
return handleVideoFiles(newValue);
|
||
}
|
||
|
||
if (isAcceptedType("image") || isAcceptedType("pdf")) {
|
||
return handleImageAndPdf(newValue);
|
||
}
|
||
|
||
return [];
|
||
}
|
||
|
||
// 设置文件列表
|
||
fileList.value = processFilesByAccept();
|
||
}
|
||
|
||
/**
|
||
* 处理文件预览
|
||
* @param {Object} file - 文件对象
|
||
*/
|
||
function handlePreview(file) {
|
||
const type = getFileType(file);
|
||
|
||
// 触发预览事件(移到前面可以让父组件有机会拦截默认预览行为)
|
||
emit('preview', { file });
|
||
|
||
// 根据文件类型执行不同的预览操作
|
||
if (type === "video" && file.name) {
|
||
previewVideoUrl.value = file.name;
|
||
showPreview.value = true;
|
||
} else if (type === "pdf" && file.name) {
|
||
window.open(file.name);
|
||
} else if (type === "image") {
|
||
previewImage(file.url);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理文件删除
|
||
* @param {Object} file - 文件对象
|
||
* @param {number} index - 文件索引
|
||
* @param {Event} event - 事件对象
|
||
*/
|
||
function handleRemove(file, index, event) {
|
||
// 阻止事件冒泡
|
||
if (event) {
|
||
event.stopPropagation();
|
||
event.preventDefault();
|
||
}
|
||
|
||
// 触发删除事件(移到前面可以让父组件有机会拦截删除行为)
|
||
emit('remove', { file, index });
|
||
|
||
// 从列表中移除并更新值
|
||
fileList.value.splice(index, 1);
|
||
updateFileList();
|
||
}
|
||
|
||
/**
|
||
* 处理文件下载
|
||
* @param {Object} file - 文件对象
|
||
* @param {Event} event - 事件对象
|
||
*/
|
||
function handleDownload(file, event) {
|
||
// 阻止事件冒泡
|
||
if (event) {
|
||
event.stopPropagation();
|
||
event.preventDefault();
|
||
}
|
||
|
||
// 触发下载事件(移到前面可以让父组件有机会拦截默认下载行为)
|
||
emit('download', { file });
|
||
|
||
const url = file.name || file.url;
|
||
if (!url) return;
|
||
|
||
// 创建下载链接
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = file.name || url.split('/').pop() || '下载文件';
|
||
a.target = '_blank';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
}
|
||
|
||
/**
|
||
* 处理文件选择
|
||
* @param {Event} event - 事件对象
|
||
*/
|
||
async function handleFileChange(event) {
|
||
const files = event.target.files;
|
||
if (!files || files.length === 0) return;
|
||
|
||
console.log("文件选择触发:", files.length, "个文件");
|
||
|
||
// 检查是否超过最大数量
|
||
if (props.max > 0 && fileList.value.length + files.length > props.max) {
|
||
message.warning(`最多只能上传${props.max}个文件`);
|
||
return;
|
||
}
|
||
|
||
// 处理文件
|
||
await handleFiles(files);
|
||
|
||
// 清空input的值,允许重复选择相同文件
|
||
event.target.value = '';
|
||
}
|
||
|
||
/**
|
||
* 处理拖拽相关的事件
|
||
* @param {DragEvent} event - 拖拽事件
|
||
* @param {string} type - 事件类型 ('enter', 'leave', 'drop')
|
||
*/
|
||
function handleDrag(event, type) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
|
||
// 拖拽状态控制
|
||
if (type === 'enter') {
|
||
dragActive.value = true;
|
||
} else if (type === 'leave') {
|
||
dragActive.value = false;
|
||
} else if (type === 'drop') {
|
||
dragActive.value = false;
|
||
|
||
// 如果组件禁用或没有文件,不处理
|
||
if (props.disabled) return;
|
||
|
||
const files = event.dataTransfer.files;
|
||
if (!files || files.length === 0) return;
|
||
|
||
// 检查是否超过最大数量
|
||
if (props.max > 0 && fileList.value.length + files.length > props.max) {
|
||
message.warning(`最多只能上传${props.max}个文件`);
|
||
return;
|
||
}
|
||
|
||
// 处理所有文件
|
||
handleFiles(files);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理文件列表
|
||
* @param {FileList} files - 文件列表
|
||
*/
|
||
async function handleFiles(files) {
|
||
for (let i = 0; i < files.length; i++) {
|
||
const file = files[i];
|
||
|
||
// 验证文件
|
||
const isValid = await validateBeforeUpload(file);
|
||
if (isValid !== false) { // 只要不是明确返回false就继续上传
|
||
// 上传文件
|
||
await UploadHandler.uploadFile(file);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 触发文件选择对话框
|
||
*/
|
||
function triggerFileInput() {
|
||
if (props.disabled) return;
|
||
console.log("触发文件选择器");
|
||
if (inputFileRef.value) {
|
||
inputFileRef.value.click();
|
||
} else {
|
||
console.error("文件输入引用未找到!");
|
||
}
|
||
}
|
||
|
||
// 组件挂载时的处理
|
||
onMounted(() => {
|
||
console.log("组件挂载完成");
|
||
// 检查文件输入引用是否存在
|
||
console.log("文件输入引用状态:", inputFileRef.value ? "已找到" : "未找到");
|
||
});
|
||
|
||
// 监听value变化
|
||
watch(
|
||
() => props.value,
|
||
(newValue, oldValue) => {
|
||
// 如果值相等,不处理
|
||
if (isEqual(newValue, oldValue)) return;
|
||
|
||
// 保存旧值并初始化文件列表
|
||
previousValue.value = newValue;
|
||
initFileList();
|
||
},
|
||
{ immediate: true }
|
||
);
|
||
|
||
// 暴露组件内部属性
|
||
defineExpose({
|
||
isUploading,
|
||
fileList
|
||
});
|
||
|
||
// 添加一个计算属性用于在模板中使用
|
||
const numWidth = computed(() => {
|
||
return parseInt(String(props.itemWidth).replace(/[^0-9]/g, ''));
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div
|
||
class="x-upload-container"
|
||
:data-instance-id="instanceId"
|
||
@dragenter="(e) => handleDrag(e, 'enter')"
|
||
@dragover.prevent
|
||
@dragleave="(e) => handleDrag(e, 'leave')"
|
||
@drop="(e) => handleDrag(e, 'drop')"
|
||
>
|
||
<!-- 隐藏的文件输入框 -->
|
||
<input
|
||
ref="inputFileRef"
|
||
type="file"
|
||
class="hidden"
|
||
:multiple="props.multiple"
|
||
:accept="props.accept"
|
||
@change="handleFileChange"
|
||
/>
|
||
|
||
<!-- 文件列表 -->
|
||
<div class="x-upload-file-list" :class="{ 'drag-active': dragActive }">
|
||
<!-- 已上传的文件项 -->
|
||
<div
|
||
v-for="(file, index) in fileList"
|
||
:key="file.id || index"
|
||
class="x-upload-file-item"
|
||
:style="{
|
||
width: memoizedComputedStyles.fileWidth,
|
||
height: memoizedComputedStyles.fileHeight
|
||
}"
|
||
@click="handlePreview(file)"
|
||
>
|
||
<!-- 文件预览 -->
|
||
<div class="x-upload-file-preview">
|
||
<!-- 图片预览 -->
|
||
<img
|
||
v-if="file.url"
|
||
:src="file.url"
|
||
class="x-upload-image"
|
||
alt="file preview"
|
||
/>
|
||
|
||
<!-- 上传中状态 -->
|
||
<div v-if="file.status === 'uploading'" class="x-upload-progress-mask">
|
||
<div class="x-upload-progress">
|
||
<div
|
||
class="x-upload-progress-info"
|
||
:style="{
|
||
fontSize: memoizedComputedStyles.progressFontSize,
|
||
marginBottom: numWidth.value <= 50 ? '4px' : '8px'
|
||
}"
|
||
>
|
||
上传中 {{ file.percentage }}%
|
||
</div>
|
||
<div class="x-upload-progress-bar" :style="{ height: memoizedComputedStyles.progressBarHeight }">
|
||
<div class="x-upload-progress-inner" :style="{ width: `${file.percentage}%` }"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 重试中状态 -->
|
||
<div v-if="file.status === 'retrying'" class="x-upload-progress-mask">
|
||
<div class="x-upload-progress">
|
||
<div
|
||
class="x-upload-progress-info"
|
||
:style="{
|
||
fontSize: memoizedComputedStyles.progressFontSize,
|
||
marginBottom: numWidth.value <= 50 ? '4px' : '8px'
|
||
}"
|
||
>
|
||
重试中 {{ retryingFiles[file.id]?.count || 0 }}/{{ props.maxRetries }}
|
||
</div>
|
||
<div class="x-upload-progress-bar" :style="{ height: memoizedComputedStyles.progressBarHeight }">
|
||
<div class="x-upload-progress-inner" :style="{ width: `${file.percentage}%` }"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 错误状态 -->
|
||
<div v-if="file.status === 'error'" class="x-upload-error-mask">
|
||
<div class="x-upload-error-icon" :style="{ fontSize: numWidth.value <= 50 ? '16px' : '24px' }">
|
||
<svg viewBox="0 0 24 24" fill="currentColor" :style="{ width: numWidth.value <= 50 ? '16px' : '24px', height: numWidth.value <= 50 ? '16px' : '24px' }">
|
||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
|
||
</svg>
|
||
</div>
|
||
<div class="x-upload-error-text" :style="{ fontSize: memoizedComputedStyles.progressFontSize }">上传失败</div>
|
||
</div>
|
||
|
||
<!-- 悬浮操作按钮区域 -->
|
||
<div v-if="file.status === 'finished'" class="x-upload-hover-actions">
|
||
<!-- 外部自定义插槽 -->
|
||
<slot
|
||
name="file-actions"
|
||
:file="file"
|
||
:index="index"
|
||
:handle-preview="() => handlePreview(file)"
|
||
:handle-remove="(e) => handleRemove(file, index, e)"
|
||
:handle-download="(e) => handleDownload(file, e)"
|
||
>
|
||
<!-- 默认按钮区域 -->
|
||
<div class="x-upload-action-buttons">
|
||
<!-- 查看按钮 -->
|
||
<button
|
||
class="x-upload-action-button x-upload-preview-button"
|
||
type="button"
|
||
title="查看"
|
||
:style="{ width: memoizedComputedStyles.actionButtonSize, height: memoizedComputedStyles.actionButtonSize }"
|
||
@click.stop="handlePreview(file)"
|
||
>
|
||
<svg viewBox="0 0 24 24" fill="currentColor" :style="{ width: memoizedComputedStyles.actionIconSize, height: memoizedComputedStyles.actionIconSize }">
|
||
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/>
|
||
</svg>
|
||
</button>
|
||
|
||
<!-- 下载按钮 -->
|
||
<button
|
||
v-if="props.showDownload"
|
||
class="x-upload-action-button x-upload-download-button"
|
||
type="button"
|
||
title="下载"
|
||
:style="{ width: memoizedComputedStyles.actionButtonSize, height: memoizedComputedStyles.actionButtonSize }"
|
||
@click.stop="handleDownload(file, $event)"
|
||
>
|
||
<svg viewBox="0 0 24 24" fill="currentColor" :style="{ width: memoizedComputedStyles.actionIconSize, height: memoizedComputedStyles.actionIconSize }">
|
||
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
|
||
</svg>
|
||
</button>
|
||
|
||
<!-- 删除按钮 -->
|
||
<button
|
||
v-if="props.showDelete && !props.disabled"
|
||
class="x-upload-action-button x-upload-delete-button"
|
||
type="button"
|
||
title="删除"
|
||
:style="{ width: memoizedComputedStyles.actionButtonSize, height: memoizedComputedStyles.actionButtonSize }"
|
||
@click.stop="handleRemove(file, index, $event)"
|
||
>
|
||
<svg viewBox="0 0 24 24" fill="currentColor" :style="{ width: memoizedComputedStyles.actionIconSize, height: memoizedComputedStyles.actionIconSize }">
|
||
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</slot>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 上传按钮 -->
|
||
<div
|
||
v-if="props.max <= 0 || fileList.length < props.max"
|
||
class="x-upload-trigger"
|
||
:class="{ 'x-upload-disabled': props.disabled }"
|
||
:style="memoizedComputedStyles.uploadButton"
|
||
@click="!props.disabled && triggerFileInput()"
|
||
>
|
||
<slot>
|
||
<div class="x-upload-trigger-content">
|
||
<span class="x-upload-plus-icon" :style="{ fontSize: memoizedComputedStyles.plusIconSize }">+</span>
|
||
</div>
|
||
</slot>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 视频预览层 -->
|
||
<transition name="fade">
|
||
<div
|
||
v-if="showPreview"
|
||
class="x-video-preview-overlay"
|
||
>
|
||
<div class="x-video-preview-header">
|
||
<button
|
||
class="x-video-preview-close"
|
||
@click="showPreview = false"
|
||
type="button"
|
||
>
|
||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="x-video-preview-content">
|
||
<video
|
||
class="x-video-player"
|
||
controls
|
||
>
|
||
<source :src="previewVideoUrl" type="video/mp4">
|
||
您的浏览器不支持视频播放
|
||
</video>
|
||
</div>
|
||
</div>
|
||
</transition>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* 基础容器样式 */
|
||
.x-upload-container {
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
width: 100%;
|
||
}
|
||
|
||
.x-upload-file-list {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
width: 100%;
|
||
padding: 8px 0;
|
||
border-radius: 4px;
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.x-upload-file-list.drag-active {
|
||
background-color: rgba(45, 140, 240, 0.05);
|
||
border: 1px dashed #462aa0;
|
||
}
|
||
|
||
/* 文件项样式 */
|
||
.x-upload-file-item {
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: column;
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
border: 1px solid #e5e6eb;
|
||
background-color: #fafafa;
|
||
transition: all 0.3s;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.x-upload-file-preview {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
position: relative;
|
||
overflow: hidden;
|
||
height: 100%;
|
||
width: 100%;
|
||
}
|
||
|
||
.x-upload-image {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: contain;
|
||
}
|
||
|
||
/* 统一遮罩层样式 */
|
||
.x-upload-progress-mask,
|
||
.x-upload-error-mask,
|
||
.x-upload-hover-actions {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background-color: rgba(0, 0, 0, 0.5);
|
||
color: white;
|
||
text-align: center;
|
||
padding: 4px;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
/* 悬浮操作区 */
|
||
.x-upload-hover-actions {
|
||
opacity: 0;
|
||
transition: opacity 0.2s;
|
||
}
|
||
|
||
.x-upload-file-item:hover .x-upload-hover-actions {
|
||
opacity: 1;
|
||
}
|
||
|
||
/* 按钮组 */
|
||
.x-upload-action-buttons {
|
||
display: flex;
|
||
justify-content: center;
|
||
gap: 4px;
|
||
flex-wrap: wrap;
|
||
max-width: 100%;
|
||
padding: 2px;
|
||
}
|
||
|
||
/* 响应式按钮排列 */
|
||
@media (max-width: 768px) {
|
||
.x-upload-file-item[style*="width: 40px"] .x-upload-action-buttons,
|
||
.x-upload-file-item[style*="width:40px"] .x-upload-action-buttons,
|
||
.x-upload-file-item[style*="width: 30px"] .x-upload-action-buttons,
|
||
.x-upload-file-item[style*="width:30px"] .x-upload-action-buttons,
|
||
.x-upload-file-item[style*="width: 20px"] .x-upload-action-buttons,
|
||
.x-upload-file-item[style*="width:20px"] .x-upload-action-buttons {
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
|
||
.x-upload-action-buttons {
|
||
gap: 2px;
|
||
}
|
||
}
|
||
|
||
/* 操作按钮 */
|
||
.x-upload-action-button {
|
||
border-radius: 50%;
|
||
background-color: white;
|
||
border: none;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
color: #606266;
|
||
padding: 0;
|
||
min-width: 16px;
|
||
min-height: 16px;
|
||
}
|
||
|
||
|
||
|
||
.x-upload-preview-button:hover {
|
||
background-color: #e6f7ff;
|
||
color: #1890ff;
|
||
}
|
||
|
||
.x-upload-download-button:hover {
|
||
background-color: #e6fffb;
|
||
color: #13c2c2;
|
||
}
|
||
|
||
.x-upload-delete-button:hover {
|
||
background-color: #fff1f0;
|
||
color: #f5222d;
|
||
}
|
||
|
||
/* 进度条相关 */
|
||
.x-upload-progress {
|
||
width: 80%;
|
||
text-align: center;
|
||
max-width: 100%;
|
||
}
|
||
|
||
.x-upload-progress-info {
|
||
margin-bottom: 8px;
|
||
word-break: break-word;
|
||
white-space: normal;
|
||
overflow: hidden;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
}
|
||
|
||
.x-upload-progress-bar {
|
||
width: 100%;
|
||
background-color: rgba(255, 255, 255, 0.3);
|
||
border-radius: 3px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.x-upload-progress-inner {
|
||
height: 100%;
|
||
background-color: #18a058;
|
||
transition: width 0.2s linear;
|
||
}
|
||
|
||
/* 错误状态 */
|
||
.x-upload-error-icon {
|
||
color: #f56c6c;
|
||
margin-bottom: 4px;
|
||
line-height: 1;
|
||
}
|
||
|
||
.x-upload-error-text {
|
||
word-break: break-word;
|
||
white-space: normal;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
/* 上传触发区域 */
|
||
.x-upload-trigger {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 4px;
|
||
background-color: #fafafa;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
min-width: 30px;
|
||
min-height: 30px;
|
||
position: relative;
|
||
}
|
||
|
||
.x-upload-trigger:hover:not(.x-upload-disabled) {
|
||
border-color: #462aa0 !important;
|
||
color: #462aa0;
|
||
}
|
||
|
||
.x-upload-disabled {
|
||
cursor: not-allowed;
|
||
color: #c0c4cc;
|
||
background-color: #f5f7fa;
|
||
border-color: #e4e7ed;
|
||
}
|
||
|
||
.x-upload-trigger-content {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.x-upload-plus-icon {
|
||
font-weight: bold;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.hidden {
|
||
display: none;
|
||
}
|
||
|
||
/* 视频预览 */
|
||
.x-video-preview-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
z-index: 9999;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background-color: rgba(0, 0, 0, 0.85);
|
||
backdrop-filter: blur(5px);
|
||
}
|
||
|
||
.x-video-preview-header {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
padding: 16px;
|
||
}
|
||
|
||
.x-video-preview-close {
|
||
color: white;
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
width: 24px;
|
||
height: 24px;
|
||
}
|
||
|
||
.x-video-preview-close svg {
|
||
width: 24px;
|
||
height: 24px;
|
||
transition: transform 0.3s;
|
||
}
|
||
|
||
.x-video-preview-close:hover svg {
|
||
transform: rotate(90deg);
|
||
}
|
||
|
||
.x-video-preview-content {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 0 24px 24px;
|
||
}
|
||
|
||
.x-video-player {
|
||
max-width: 100%;
|
||
max-height: 80vh;
|
||
background-color: black;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||
border-radius: 4px;
|
||
aspect-ratio: 16 / 9;
|
||
}
|
||
|
||
/* 动画 */
|
||
.fade-enter-active,
|
||
.fade-leave-active {
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.fade-enter-from,
|
||
.fade-leave-to {
|
||
opacity: 0;
|
||
transform: scale(0.95);
|
||
}
|
||
</style>
|