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

1689 lines
43 KiB
Vue
Raw Normal View History

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