chat-pc/src/components/x-naive-ui/x-n-upload/index.vue

1689 lines
43 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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