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>
|