(function ($) { "use strict"; // ============================================================================ // G3File 构造函数 // ============================================================================ function G3File(container, options) { // 实例隔离 this.$container = $(container); this.options = $.extend({}, this.defaults, options); this.instanceId = this.generateUniqueId(); this.files = []; this.selectedFiles = new Set(); this.objectUrls = new Map(); this.language = this.languages[this.options.language] || this.languages["zh-CN"]; // 操作菜单状态 this.actionMenuState = { open: false, fileId: null, anchorEl: null, items: [], }; // 任务管理 this.tasks = []; this.taskStats = { total: 0, success: 0, fail: 0, }; this.init(); } // ============================================================================ // 原型方法 // ============================================================================ G3File.prototype = { // ============================================================================ // 配置和常量 // ============================================================================ /** * 默认配置 */ defaults: { data: [], onDelete: null, onDownload: null, onSelect: null, onUpload: null, onUploadSuccess: null, onUploadError: null, onUploadComplete: null, onPreview: null, getActionMenu: null, language: "zh-CN", maxFileSize: 10 * 1024 * 1024, // 上传配置 uploadConfig: { aliyun: { // 阿里云配置是可选的 // getUploadToken 是必须的,用于动态获取上传凭证 getUploadToken: null, // 动态获取上传凭证的函数,返回 Promise }, customUpload: null, // 自定义上传方法 }, // 全局权限(支持对象或函数) globalPermission: { select: true, upload: true, download: true, delete: true, view: true, }, // 单文件权限解析函数(仅支持函数) filePermissionResolver: null, }, /** * 多语言配置 */ languages: { "zh-CN": { selectAll: "全选", download: "下载", downloadBatch: "批量下载", delete: "删除", upload: "上传", select: "选择", more: "更多", confirmDelete: "确定要删除选中的 {count} 个文件吗?", downloading: "准备下载 {count} 个文件", deleting: "删除中...", name: "名称", size: "大小", date: "日期", pending: "未上传", selectFiles: "选择文件", emptyTip1: "拖拽文件到这里 …", emptyTip2: "支持多文件同时上传/下载", emptyTip3: "支持粘贴图片上传", fileSizeError: "文件大小超过限制", preview: "预览", downloadingStatus: "下载中:", downloadingFile: "正在下载:", downloadComplete: "下载完成", downloadResult: "共{total}文件,{success}成功,{failed}失败", close: "关闭", taskTotal: "总计", taskSuccess: "成功", taskFail: "失败", batchDownload: "批量下载", batchUpload: "批量上传", error: "错误", downloadError: "下载失败,请检查文件是否有效", }, "en-US": { selectAll: "Select All", download: "Download", downloadBatch: "Batch Download", delete: "Delete", upload: "Upload", select: "Select", more: "More", confirmDelete: "Are you sure you want to delete {count} selected file(s)?", downloading: "Preparing to download {count} file(s)", deleting: "Deleting...", name: "Name", size: "Size", date: "Date", pending: "Pending upload", selectFiles: "Select Files", emptyTip1: "Drag files here ...", emptyTip2: "Supports multiple file upload/download", emptyTip3: "Supports pasting images to upload", fileSizeError: "File size exceeds limit", preview: "Preview", downloadingStatus: "Downloading:", downloadingFile: "Downloading:", downloadComplete: "Download complete", downloadResult: "Total {total} files, {success} succeeded, {failed} failed", close: "Close", taskTotal: "Total", taskSuccess: "Success", taskFail: "Fail", batchDownload: "Batch Download", batchUpload: "Batch Upload", error: "Error", downloadError: "Download failed, please check if the file is valid", }, }, /** * 静态图标映射 */ staticIconMap: { download: ` `, delete: ` `, select: ` `, upload: ` `, preview: ` `, more: ` `, }, // ============================================================================ // 初始化和核心方法 // ============================================================================ /** * 加载脚本 * @param {Array} items - 脚本配置数组 * @param {Function} callback - 加载完成回调 */ loadScripts: function (items, callback) { var remaining = items.length; function done() { remaining--; if (remaining === 0) { callback && callback(); } } items.forEach(function (item) { // 已加载 if (item.check && item.check()) { done(); return; } // 防重复加载 var key = item.key; if (window[key + "_loading"]) { var timer = setInterval(function () { if (item.check && item.check()) { clearInterval(timer); done(); } }, 50); return; } window[key + "_loading"] = true; var script = document.createElement("script"); script.src = item.src; script.onload = function () { window[key + "_loading"] = false; done(); }; script.onerror = function () { window[key + "_loading"] = false; console.error(key + " load failed"); done(); }; document.head.appendChild(script); }); }, /** * 初始化 * @returns {G3File} 实例 */ init: function () { var self = this; this.loadScripts( [ { key: "fileSaver", src: "/custom-file/libs/FileSaver.min.js", check: function () { return typeof window.saveAs === "function"; }, }, { key: "jsZip", src: "/custom-file/libs/jszip.min.js", check: function () { return typeof window.JSZip === "function"; }, }, ], function () { self.render(); self.bindEvents(); self.updateUI(); if (self.options.data && self.options.data.length) { self.setFiles(self.options.data); } }, ); return this; }, // ============================================================================ // 工具方法 // ============================================================================ /** * 生成唯一ID * @returns {string} 唯一ID */ generateUniqueId: function () { return "file_" + Math.random().toString(36).slice(2) + Date.now(); }, /** * 获取文件图标 * @param {string} filename - 文件名 * @returns {string} 图标HTML */ getFileIcon: function (filename) { var ext = filename.split(".").pop().toLowerCase(); var iconMap = { pdf: '', png: '', jpg: '', default: '', }; if (this.isImageFile(filename)) { return ""; } return iconMap[ext] || iconMap.default; }, /** * 压缩图片 * @param {File} file - 图片文件 * @param {Object} options - 压缩选项 * @returns {Promise} 压缩后的文件 */ compressImage: async function (file, options = {}) { const { minSize = 300 * 1024, maxSize = 1.2 * 1024 * 1024, maxWidth = 1920, maxHeight = 1920, } = options; if (!file || !file.type.startsWith("image/")) return file; const originSize = file.size; if (originSize <= maxSize) { return file; } const img = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const image = new Image(); image.onload = () => resolve(image); image.onerror = reject; image.src = reader.result; }; reader.onerror = reject; reader.readAsDataURL(file); }); const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); let w = img.width; let h = img.height; const scale = Math.min(1, maxWidth / w, maxHeight / h); w = Math.round(w * scale); h = Math.round(h * scale); canvas.width = w; canvas.height = h; ctx.drawImage(img, 0, 0, w, h); const getBlob = (q) => new Promise((resolve) => { canvas.toBlob((b) => resolve(b), "image/jpeg", q); }); let quality = 0.92; if (originSize > 4 * 1024 * 1024) { quality = 0.88; } const blob = await getBlob(quality); if (blob && blob.size < minSize) { const safe = await getBlob(0.97); return safe || file; } return blob || file; }, /** * 判断是否为图片文件 * @param {string} filename - 文件名 * @returns {boolean} 是否为图片文件 */ isImageFile: function (filename) { if (!filename || typeof filename !== "string") return false; const ext = filename.slice(filename.lastIndexOf(".") + 1).toLowerCase(); const imageExts = new Set(["jpg", "jpeg", "png", "gif", "bmp", "webp"]); return imageExts.has(ext); }, /** * 格式化文件大小 * @param {number} bytes - 文件大小(字节) * @returns {string} 格式化后的大小 */ formatFileSize: function (bytes) { if (bytes === null || bytes === undefined || isNaN(bytes) || bytes <= 0) { return "-"; } var k = 1024; var sizes = ["B", "KB", "MB", "GB", "TB"]; var i = Math.floor(Math.log(bytes) / Math.log(k)); i = Math.max(0, Math.min(i, sizes.length - 1)); return (bytes / Math.pow(k, i)).toFixed(2) + " " + sizes[i]; }, /** * 格式化日期 * @param {string} dateStr - 日期字符串 * @returns {string} 格式化后的日期 */ formatDate: function (dateStr) { if (!dateStr) return "-"; var date = new Date(dateStr); if (isNaN(date.getTime())) { return "-"; } var year = date.getFullYear(); var month = ("0" + (date.getMonth() + 1)).slice(-2); var day = ("0" + date.getDate()).slice(-2); var hours = ("0" + date.getHours()).slice(-2); var minutes = ("0" + date.getMinutes()).slice(-2); var seconds = ("0" + date.getSeconds()).slice(-2); return ( year + "-" + month + "-" + day + " " + hours + ":" + minutes + ":" + seconds ); }, /** * 获取唯一文件名 * @param {string} fileName - 原始文件名 * @param {Set} files - 已存在的文件名集合 * @returns {string} 唯一文件名 */ getUniqueFileName: function (fileName, files) { const originalFileName = fileName; let uniqueFileName = fileName; let counter = 1; while (files.has(uniqueFileName)) { const fileNameWithoutExtension = originalFileName.replace( /\.[^/.]+$/, "", ); const extension = originalFileName.slice( originalFileName.lastIndexOf("."), ); uniqueFileName = `${fileNameWithoutExtension} (${counter})${extension}`; counter++; } return uniqueFileName; }, /** * 获取文件Blob * @param {string} url - 文件URL * @returns {Promise} 文件二进制数据 */ getFileBlob: async function (url) { if (!url) { throw new Error("无效的文件URL"); } const res = await fetch(url); if (!res.ok) { throw new Error("网络请求失败,状态码:" + res.status); } const buffer = await res.arrayBuffer(); return buffer; }, // ============================================================================ // UI 渲染相关方法 // ============================================================================ /** * 渲染 * @returns {G3File} 实例 */ render: function () { const html = ` ${this.language.selectAll} ${this.staticIconMap.download} ${this.language.downloadBatch} ${this.staticIconMap.delete} ${this.language.delete} main ${this.language.emptyTip1} ${this.language.emptyTip2} ${this.language.emptyTip3} Task Running × 0 / 0 0% Total: 0 | Success: 0 | Fail: 0 `; this.$container.html(html); this.$fileList = this.$container.find(".main"); this.$emptyTip = this.$container.find(".empty-tip"); this.$checkAll = this.$container.find(".check-all-checkbox"); this.$btnDownload = this.$container.find(".download-all"); this.$btnDelete = this.$container.find(".delete-all"); this.$btnUpload = this.$container.find(".upload"); this.$btnSelect = this.$container.find(".select"); this.$fileInput = this.$container.find(".file-input"); this.$pendingCount = this.$container.find(".pending-number"); this.$taskDock = this.$container.find(".task-dock"); this.$notificationContainer = this.$container.find( ".notification-container", ); this.$uiLock = this.$container.find(".ui-lock"); // 通知管理 this.notifications = []; return this; }, /** * 渲染操作菜单 */ renderActionMenu: function () { var actionMenu = this.$container.find(".g3-global-menu"); if (!actionMenu.length) { this.$container.append(''); actionMenu = this.$container.find(".g3-global-menu"); } var actionMenuState = this.actionMenuState; if (!actionMenuState.open) { actionMenu.removeClass("is-open"); return; } var html = ""; actionMenuState.items.forEach(function (item) { var isDisabled = false; if (typeof item.disabled === "function") { isDisabled = item.disabled(self.getFileById(actionMenuState.fileId)); } else if (item.disabled !== undefined) { isDisabled = item.disabled; } var disabledClass = isDisabled ? "is-disabled" : ""; html += `${item.title}`; }); actionMenu.html(html); var self = this; actionMenu .find(".g3-action-menu-item") .off("click") .on("click", function (e) { e.stopPropagation(); var idx = $(this).index(); var item = actionMenuState.items[idx]; var isDisabled = false; if (typeof item.disabled === "function") { isDisabled = item.disabled( self.getFileById(actionMenuState.fileId), ); } else if (item.disabled !== undefined) { isDisabled = item.disabled; } if (isDisabled) return; item.onClick && item.onClick(self.getFileById(actionMenuState.fileId)); self.closeActionMenu(); }); var $anchor = $(actionMenuState.anchorEl).closest(".g3-file-action.more"); var rect = $anchor[0].getBoundingClientRect(); var $mainContainer = this.$container.find(".main"); var mainRect = $mainContainer[0].getBoundingClientRect(); var menuWidth = 160; var itemHeight = 36; var menuHeight = actionMenuState.items.length * itemHeight; var top = rect.bottom; var left = rect.right - menuWidth; var spaceBottom = mainRect.bottom - rect.bottom; // main 容器底部到锚点的距离 var spaceTop = rect.top - mainRect.top; // 锚点到 main 容器顶部的距离 var openUp = spaceBottom < menuHeight && spaceTop > menuHeight; if (openUp) { top = rect.top - menuHeight; // 在锚点上方显示 } // 确保菜单不超出 main 容器左右边界 if (left < mainRect.left) { left = mainRect.left; } if (left + menuWidth > mainRect.right) { left = mainRect.right - menuWidth; } // 确保菜单不超出 main 容器上下边界 if (top < mainRect.top) { top = mainRect.top; } if (top + menuHeight > mainRect.bottom) { top = mainRect.bottom - menuHeight; } actionMenu.css({ top: top, left: left, }); requestAnimationFrame(function () { actionMenu.addClass("is-open"); }); }, /** * 创建文件项 * @param {Object} file - 文件对象 * @returns {string} 文件项HTML */ createFileItem: function (file) { const previewDisabled = !this.hasPermission(file, "view"); const downloadDisabled = !this.hasPermission(file, "download"); const deleteDisabled = !this.hasPermission(file, "delete"); let content = ""; if (this.isImageFile(file.name)) { content = ` ${this.staticIconMap.error || ''} `; } else { content = this.getFileIcon(file.name); } let statusBadge = ""; switch (file.status) { case "pending": statusBadge = ` ${this.language.pending} `; break; case "uploading": statusBadge = ` 上传中 `; break; case "failed": statusBadge = ` 上传失败 `; break; } const deleteButton = ` ${this.staticIconMap.delete} `; return ` ${content} ${statusBadge} ${deleteButton} ${file.name} ${this.formatFileSize(file.size)} ${this.formatDate(file.uploadDate)} ${this.staticIconMap.preview} ${this.staticIconMap.download} ${this.staticIconMap.more} `; }, /** * 渲染文件列表 */ renderFileList: function () { if (this.files.length === 0) { this.$fileList.hide(); this.$emptyTip.show(); return; } this.$fileList.show(); this.$emptyTip.hide(); var html = ""; var self = this; this.files.forEach(function (file) { html += self.createFileItem(file); }); this.$fileList.html(html); this.bindFileItemEvents(); }, /** * 更新UI */ updateUI: function () { var self = this; var hasSelectedFiles = this.selectedFiles.size > 0; // 更新全局按钮状态 this.$btnSelect?.prop("disabled", !this.hasPermission(null, "select")); this.$btnUpload?.prop("disabled", !this.hasPermission(null, "upload")); // 批量下载按钮:需要全局权限且有选中文件 var canBatchDownload = this.hasPermission(null, "download") && hasSelectedFiles; this.$btnDownload?.prop("disabled", !canBatchDownload); // 批量删除按钮:需要全局权限且有选中文件 var canBatchDelete = this.hasPermission(null, "delete") && hasSelectedFiles; this.$btnDelete?.prop("disabled", !canBatchDelete); // 更新全选checkbox状态 var allSelected = this.files.length > 0 && this.selectedFiles.size === this.files.length; this.$checkAll?.prop("checked", allSelected); // 更新待上传数量 var pendingCount = this.files.filter( (file) => file.status === "pending", ).length; this.$pendingCount?.text(pendingCount); // 更新文件项状态 this.$fileList.find(".g3-file-item").each(function () { var $item = $(this); var fileId = $item.data("id"); var file = self.files.find((f) => f.id === fileId); if (!file) return; // 更新checkbox选中状态 var isSelected = self.selectedFiles.has(fileId); $item.find(".g3-file-checkbox").prop("checked", isSelected); // 更新按钮权限状态 var canDownload = self.hasPermission(file, "download"); var canView = self.hasPermission(file, "view"); var canDelete = self.hasPermission(file, "delete"); $item .find(".g3-file-action.download") .toggleClass("is-disabled", !canDownload); $item .find(".g3-file-action.preview") .toggleClass("is-disabled", !canView); $item .find(".g3-file-delete-btn") .toggleClass("is-disabled", !canDelete); }); }, // ============================================================================ // 事件绑定相关方法 // ============================================================================ /** * 绑定事件 */ bindEvents: function () { var self = this; // 选择全部 this.$checkAll.off("change").on("change", function () { var checked = $(this).is(":checked"); self.selectedFiles.clear(); if (checked) { self.files.forEach(function (file) { self.selectedFiles.add(file.id); }); } self.updateUI(); }); // 批量下载 this.$btnDownload.off("click").on("click", function () { var selectedFiles = self.getSelectedFiles(); if (selectedFiles.length === 0) return; self.handleDownload(selectedFiles, { batch: true }); }); // 批量删除 this.$btnDelete.off("click").on("click", function () { var selectedFiles = self.getSelectedFiles(); if (selectedFiles.length === 0) return; self.handleDelete(selectedFiles); }); // 选择文件 this.$btnSelect.off("click").on("click", function () { self.$fileInput.click(); }); // 上传 this.$btnUpload.off("click").on("click", function () { var pendingFiles = self.files.filter( (file) => file.status === "pending", ); if (typeof self.options.onUpload === "function") { self.options.onUpload(pendingFiles); } else { // 使用新的上传逻辑 self.handleUpload(self.files); } }); // 文件选择 this.$fileInput.off("change").on("change", function (e) { var files = e.target.files; if (!files || files.length === 0) return; self.handleFileSelect(files); // 重置文件输入框 self.$fileInput.val(""); }); // 点击其他地方关闭菜单 $(document) .off("click.g3file_actionmenu") .on("click.g3file_actionmenu", function (e) { if ( !$(e.target).closest(".g3-file-action.more").length && !$(e.target).closest(".g3-global-menu").length ) { self.closeActionMenu(); } }); // 拖拽文件 const $container = this.$container; $container .off("dragenter dragleave dragover drop") .on("dragenter", function (e) { e.preventDefault(); e.stopPropagation(); $container.addClass("drag-over"); }) .on("dragover", function (e) { e.preventDefault(); e.stopPropagation(); $container.addClass("drag-over"); }) .on("dragleave", function (e) { e.preventDefault(); e.stopPropagation(); if (e.target === $container[0]) { $container.removeClass("drag-over"); } }) .on("drop", function (e) { e.preventDefault(); e.stopPropagation(); $container.removeClass("drag-over"); var files = e.originalEvent.dataTransfer.files; if (files && files.length > 0) { self.handleFileSelect(files); } }); // 粘贴文件 $(document) .off("paste.g3file") .on("paste.g3file", function (e) { var clipboardData = e.originalEvent.clipboardData; var files = clipboardData.files; if (files && files.length > 0) { e.preventDefault(); self.handleFileSelect(files); } }); }, /** * 绑定文件项事件 */ bindFileItemEvents: function () { var self = this; // 预览 this.$fileList .off("click", ".preview") .on("click", ".preview", function (e) { e.preventDefault(); e.stopPropagation(); var id = $(this).closest(".g3-file-item").data("id"); var file = self.files.find((f) => f.id === id); if (!file) return; if (typeof self.options.onPreview === "function") { self.options.onPreview([file]); } }); // 下载 this.$fileList .off("click", ".download") .on("click", ".download", function (e) { e.preventDefault(); e.stopPropagation(); var id = $(this).closest(".g3-file-item").data("id"); var file = self.files.find((f) => f.id === id); if (!file) return; self.handleDownload([file], { batch: false }); }); // 复选框 this.$fileList .off("change", ".g3-file-checkbox") .on("change", ".g3-file-checkbox", function () { var id = $(this).closest(".g3-file-item").data("id"); if ($(this).is(":checked")) { self.selectedFiles.add(id); } else { self.selectedFiles.delete(id); } self.updateUI(); }); // 删除 this.$fileList .off("click", ".g3-file-delete-btn") .on("click", ".g3-file-delete-btn", function (e) { e.preventDefault(); e.stopPropagation(); var id = $(this).closest(".g3-file-item").data("id"); var file = self.files.find((f) => f.id === id); if (!file) return; self.handleDelete([file]); }); // 文件项点击 this.$fileList .off("click", ".g3-file-item") .on("click", ".g3-file-item", function (e) { if ( $(e.target).closest( ".g3-file-action, .g3-file-checkbox, .g3-file-delete-btn", ).length ) { return; } var id = $(this).data("id"); var file = self.files.find((f) => f.id === id); if (!file) return; if (typeof self.options.onPreview === "function") { self.options.onPreview([file]); } }); // 更多按钮 this.$fileList.off("click", ".more").on("click", ".more", function (e) { e.preventDefault(); e.stopPropagation(); var $el = $(this).closest(".g3-file-action"); var id = $el.closest(".g3-file-item").data("id"); var file = self.getFileById(id); if (!file) return; if ( self.actionMenuState.open && self.actionMenuState.fileId === file.id ) { self.closeActionMenu(); return; } self.openActionMenu($el, file); }); }, /** * 绑定任务面板事件 */ bindTaskDockEvents: function () { const self = this; // 绑定关闭按钮事件 this.$taskDock .find('.dock-actions button[title="close"]') .off("click") .on("click", function () { self.clearTasks(); }); }, // ============================================================================ // 文件处理相关方法 // ============================================================================ /** * 处理文件选择 * @param {FileList} files - 文件列表 */ handleFileSelect: async function (files) { var self = this; var maxSize = this.options.maxFileSize || 10 * 1024 * 1024; var validFiles = []; var invalidFiles = []; for (var i = 0; i < files.length; i++) { var file = files[i]; if (file.size > maxSize) { invalidFiles.push(file); } else { validFiles.push(file); } } if (invalidFiles.length > 0) { var errorMsg = this.language.fileSizeError || "文件大小超过限制"; console.warn( errorMsg + ": " + invalidFiles.map((f) => f.name).join(", "), ); } if (validFiles.length > 0) { var tempFiles = []; for (var j = 0; j < validFiles.length; j++) { var file = validFiles[j]; let url = ""; let compressedFile = file; if (self.isImageFile(file.name)) { if (typeof self.options.compressImage === "function") { try { compressedFile = await self.options.compressImage(file); } catch (e) { console.warn("外部压缩算法执行失败,使用内部压缩:", e); compressedFile = await self.compressImage(file); } } else { compressedFile = await self.compressImage(file); } url = URL.createObjectURL(compressedFile); self.objectUrls.set(self.generateUniqueId(), url); } tempFiles.push({ id: self.generateUniqueId(), name: file.name, size: compressedFile.size, uploadDate: new Date().toISOString(), url: url, file: compressedFile, status: "pending", }); } self.setFiles(self.files.concat(tempFiles)); if (typeof self.options.onSelect === "function") { self.options.onSelect(validFiles); } self.autoScrollToBottom(); } }, /** * 自动滚动到底部 */ autoScrollToBottom: function () { this.$fileList.scrollTop(this.$fileList[0].scrollHeight); }, /** * 处理下载 * @param {Array} files - 文件数组 * @param {Object} options - 下载选项 */ handleDownload: function (files, options) { if (typeof this.options.onDownload === "function") { this.options.onDownload(files, options); return; } if (options.batch) { this.internalDownloadBatch(files); } else { this.internalDownload(files[0]); } }, /** * 处理删除 * @param {Array} files - 文件数组 */ handleDelete: function (files) { var self = this; if (typeof this.options.onDelete === "function") { this.options.onDelete(files, function (success) { if (success) { self.internalDelete(files); } }); return; } this.internalDelete(files); }, /** * 内部下载 * @param {Object} file - 文件对象 */ internalDownload: async function (file) { if (!file) { console.warn("下载失败:文件对象无效", file); this.showNotification({ title: this.language.error || "错误", message: this.language.downloadError || "下载失败,请检查文件是否有效", type: "error", duration: 5000, }); return; } console.log("开始下载文件:", file.name); try { let blob; if (file.status === "uploaded" && file.url) { const buffer = await this.getFileBlob(file.url); blob = new Blob([buffer]); } else if (file.file) { blob = file.file; } else { console.warn("下载失败:文件没有URL或本地文件对象", file); this.showNotification({ title: this.language.error || "错误", message: this.language.downloadError || "下载失败,请检查文件是否有效", type: "error", duration: 5000, }); return; } saveAs(blob, file.name || "未命名文件"); } catch (err) { console.error("文件下载失败:", err); this.showNotification({ title: this.language.error || "错误", message: this.language.downloadError || "下载失败,请检查文件是否有效", type: "error", duration: 5000, }); } }, /** * 内部批量下载 * @param {Array} files - 文件数组 */ internalDownloadBatch: async function (files) { if (!files || files.length === 0) return; const zip = new JSZip(); const addedFiles = new Set(); const total = files.length; let failedCount = 0; const concurrency = 3; // 为每个文件创建任务 const taskIds = []; files.forEach((file) => { const taskId = this.addTask({ name: file.name, type: "download", progress: 0, status: "running", }); taskIds.push(taskId); }); const downloadOne = async (item, index) => { const taskId = taskIds[index]; try { // 更新任务进度 this.updateTask(taskId, { progress: 0, status: "running" }); let content; if (item.status === "uploaded" && item.url) { content = await this.getFileBlob(item.url); } else if (item.file) { content = await item.file.arrayBuffer(); } else { console.warn("跳过失败文件:", item.name, "没有URL或本地文件对象"); failedCount++; this.finishTask(taskId, "fail"); return; } const uniqueFileName = this.getUniqueFileName(item.name, addedFiles); zip.file(uniqueFileName, content); addedFiles.add(uniqueFileName); // 标记任务完成 this.finishTask(taskId, "success"); } catch (err) { console.warn("跳过失败文件:", item.name, err); failedCount++; this.finishTask(taskId, "fail"); } }; try { // 使用副本,不修改原始数据 const filesCopy = [...files]; const runner = async () => { while (filesCopy.length) { const index = total - filesCopy.length; const item = filesCopy.shift(); await downloadOne(item, index); } }; await Promise.all(Array.from({ length: concurrency }, runner)); const blob = await zip.generateAsync({ type: "blob" }); const now = new Date(); const pad = (n) => (n < 10 ? "0" + n : n); const fileName = now.getFullYear() + pad(now.getMonth() + 1) + pad(now.getDate()) + pad(now.getHours()) + pad(now.getMinutes()); saveAs(blob, fileName + ".zip"); const successCount = total - failedCount; console.log(`下载完成:${successCount}/${total},失败:${failedCount}`); } catch (err) { console.error("批量下载失败:", err); } }, /** * 内部删除 * @param {Array} files - 文件数组 */ internalDelete: function (files) { var self = this; files.forEach(function (file) { // 清理本地临时连接 if (file.url && file.status === "pending") { self.objectUrls.forEach((url, key) => { if (url === file.url) { try { URL.revokeObjectURL(url); self.objectUrls.delete(key); } catch (e) { console.warn("释放ObjectURL失败:", e); } } }); } self.selectedFiles.delete(file.id); }); this.files = this.files.filter( (f) => !files.find((file) => file.id === f.id), ); this.renderFileList(); this.updateUI(); }, /** * 更新文件状态 * @param {string} fileId - 文件ID * @param {string} status - 新状态 * @param {Object} data - 附加数据 */ updateFileStatus: function (fileId, status, data) { const file = this.files.find((f) => f.id === fileId); if (file) { file.status = status; if (data) { Object.assign(file, data); } this.renderFileList(); this.updateUI(); } }, /** * 内部上传方法,支持阿里云上传 * @param {Object} file - 文件对象 * @param {Function} callback - 上传完成回调 */ internalUpload: function (file, callback) { const self = this; const aliyunConfig = this.options.uploadConfig.aliyun; if (!aliyunConfig || !aliyunConfig.getUploadToken) { callback(new Error("阿里云上传配置不完整,缺少 getUploadToken 函数")); return; } // 获取上传凭证 aliyunConfig .getUploadToken(file) .then((tokenData) => { // 这里是模拟上传过程,实际项目中需要使用阿里云 SDK 进行上传 // 模拟上传延迟 setTimeout(() => { // 模拟上传成功 const uploadedFile = { ...file, status: "uploaded", url: tokenData.uploadUrl || `http://example.com/${file.name}`, uploadDate: new Date().toISOString(), }; // 更新文件状态 self.updateFileStatus(file.id, "uploaded", { url: uploadedFile.url, uploadDate: uploadedFile.uploadDate, }); // 触发上传成功回调 if (typeof self.options.onUploadSuccess === "function") { self.options.onUploadSuccess(uploadedFile); } callback(null, uploadedFile); }, 1000); }) .catch((error) => { // 更新文件状态为失败 self.updateFileStatus(file.id, "failed"); // 触发上传失败回调 if (typeof self.options.onUploadError === "function") { self.options.onUploadError(file, error); } callback(error); }); }, /** * 处理上传逻辑 * @param {Array} files - 文件数组 */ handleUpload: function (files) { const self = this; const customUpload = this.options.uploadConfig.customUpload; let successCount = 0; let failCount = 0; // 过滤出未上传的文件 const pendingFiles = files.filter((file) => file.status === "pending"); if (pendingFiles.length === 0) { // 触发上传完成回调 if (typeof self.options.onUploadComplete === "function") { self.options.onUploadComplete(0, 0); } return; } // 逐个上传文件 pendingFiles.forEach((file) => { // 更新文件状态为上传中 self.updateFileStatus(file.id, "uploading"); if (typeof customUpload === "function") { // 使用自定义上传方法 customUpload(file) .then((uploadedFile) => { // 更新文件状态 self.updateFileStatus(file.id, "uploaded", uploadedFile); // 触发上传成功回调 if (typeof self.options.onUploadSuccess === "function") { self.options.onUploadSuccess(uploadedFile); } successCount++; checkComplete(); }) .catch((error) => { // 更新文件状态为失败 self.updateFileStatus(file.id, "failed"); // 触发上传失败回调 if (typeof self.options.onUploadError === "function") { self.options.onUploadError(file, error); } failCount++; checkComplete(); }); } else { // 使用阿里云内部上传方法 self.internalUpload(file, (error, uploadedFile) => { if (error) { failCount++; } else { successCount++; } checkComplete(); }); } }); // 检查是否所有文件都上传完成 function checkComplete() { if (successCount + failCount === pendingFiles.length) { // 触发上传完成回调 if (typeof self.options.onUploadComplete === "function") { self.options.onUploadComplete(successCount, failCount); } } } }, // ============================================================================ // 任务管理相关方法 // ============================================================================ /** * 添加任务 * @param {Object} task - 任务对象 * @returns {string} 任务ID */ addTask: function (task) { const newTask = { id: task.id || this.generateUniqueId(), name: task.name, type: task.type || "custom", progress: task.progress || 0, status: task.status || "running", }; this.tasks.push(newTask); this.updateTaskStats(); this.showTaskDock(); // 只在批量下载时锁定UI if (task.type === "download" || task.type === "upload") { this.lockUI(); } return newTask.id; }, /** * 更新任务 * @param {string} taskId - 任务ID * @param {Object} updates - 更新内容 */ updateTask: function (taskId, updates) { const task = this.tasks.find((t) => t.id === taskId); if (task) { Object.assign(task, updates); this.updateTaskDock(); } }, /** * 完成任务 * @param {string} taskId - 任务ID * @param {string} status - 任务状态 */ finishTask: function (taskId, status) { const task = this.tasks.find((t) => t.id === taskId); if (task) { task.status = status || "success"; task.progress = 100; this.updateTaskStats(); this.updateTaskDock(); const allCompleted = this.tasks.every( (t) => t.status === "success" || t.status === "fail", ); if (allCompleted) { this.unlockUI(); } } }, /** * 更新任务统计 */ updateTaskStats: function () { this.taskStats.total = this.tasks.length; this.taskStats.success = this.tasks.filter( (t) => t.status === "success", ).length; this.taskStats.fail = this.tasks.filter( (t) => t.status === "fail", ).length; }, /** * 清空任务 */ clearTasks: function () { this.tasks = []; this.taskStats = { total: 0, success: 0, fail: 0, }; this.hideTaskDock(); this.unlockUI(); }, // ============================================================================ // 通知相关方法 // ============================================================================ /** * 显示通知 * @param {Object} options - 通知配置 * @returns {string} 通知ID */ showNotification: function (options) { const defaultOptions = { title: "", message: "", type: "info", // success, warning, error, info duration: 3000, showClose: true, customClass: "", offset: 20, position: "top-right", // top-right, top-left, bottom-right, bottom-left }; const config = $.extend({}, defaultOptions, options); const notificationId = this.generateUniqueId(); // 创建通知元素 const notificationEl = $( ` ${config.title ? `${config.title}` : ""} ${config.message} ${config.showClose ? '×' : ""} `, ); // 添加到容器 this.$notificationContainer.append(notificationEl); // 计算位置 this._positionNotification(notificationEl, config); // 绑定关闭事件 if (config.showClose) { notificationEl.find(".notification-close").on("click", () => { this.closeNotification(notificationId); }); } // 自动关闭 if (config.duration > 0) { setTimeout(() => { this.closeNotification(notificationId); }, config.duration); } // 存储通知 this.notifications.push({ id: notificationId, element: notificationEl, config: config, }); return notificationId; }, /** * 关闭通知 * @param {string} notificationId - 通知ID */ closeNotification: function (notificationId) { const index = this.notifications.findIndex( (n) => n.id === notificationId, ); if (index !== -1) { const notification = this.notifications[index]; notification.element.addClass("notification-fade-out"); setTimeout(() => { notification.element.remove(); this.notifications.splice(index, 1); // 重新计算其他通知位置 this._repositionNotifications(); }, 300); } }, /** * 关闭所有通知 */ closeAllNotifications: function () { this.notifications.forEach((notification) => { notification.element.addClass("notification-fade-out"); setTimeout(() => { notification.element.remove(); }, 300); }); this.notifications = []; }, /** * 定位通知 * @param {jQuery} element - 通知元素 * @param {Object} config - 配置 */ _positionNotification: function (element, config) { const position = config.position || "top-right"; const offset = config.offset || 20; // 计算当前位置 let top, left, right, bottom; const containerRect = this.$notificationContainer[0].getBoundingClientRect(); // 计算同位置的通知数量 const samePositionNotifications = this.notifications.filter( (n) => n.config.position === position, ); const notificationHeight = 80; // 估计通知高度 const spacing = 12; // 通知间距 switch (position) { case "top-right": top = offset + samePositionNotifications.length * (notificationHeight + spacing); right = offset; break; case "top-left": top = offset + samePositionNotifications.length * (notificationHeight + spacing); left = offset; break; case "bottom-right": bottom = offset + samePositionNotifications.length * (notificationHeight + spacing); right = offset; break; case "bottom-left": bottom = offset + samePositionNotifications.length * (notificationHeight + spacing); left = offset; break; } element.css({ top: top, left: left, right: right, bottom: bottom, }); }, /** * 重新定位通知 */ _repositionNotifications: function () { // 按位置分组 const notificationsByPosition = {}; this.notifications.forEach((notification) => { const position = notification.config.position || "top-right"; if (!notificationsByPosition[position]) { notificationsByPosition[position] = []; } notificationsByPosition[position].push(notification); }); // 重新定位每个位置的通知 Object.keys(notificationsByPosition).forEach((position) => { const notifications = notificationsByPosition[position]; const offset = notifications[0].config.offset || 20; const notificationHeight = 80; const spacing = 12; notifications.forEach((notification, index) => { let top, left, right, bottom; switch (position) { case "top-right": top = offset + index * (notificationHeight + spacing); right = offset; break; case "top-left": top = offset + index * (notificationHeight + spacing); left = offset; break; case "bottom-right": bottom = offset + index * (notificationHeight + spacing); right = offset; break; case "bottom-left": bottom = offset + index * (notificationHeight + spacing); left = offset; break; } notification.element.css({ top: top, left: left, right: right, bottom: bottom, }); }); }); }, /** * 测试通知功能 */ testNotifications: function () { // 测试不同类型的通知 this.showNotification({ title: "成功", message: "操作成功完成", type: "success", duration: 5000, }); this.showNotification({ title: "警告", message: "操作可能会影响其他功能", type: "warning", duration: 5000, }); this.showNotification({ title: "错误", message: "操作失败,请重试", type: "error", duration: 5000, }); this.showNotification({ title: "信息", message: "这是一条信息通知", type: "info", duration: 5000, }); }, /** * 显示任务面板 */ showTaskDock: function () { if (this.$taskDock) { this.$taskDock.show(); this.bindTaskDockEvents(); } }, /** * 隐藏任务面板 */ hideTaskDock: function () { if (this.$taskDock) { this.$taskDock.hide(); } }, /** * 更新任务面板 */ updateTaskDock: function () { if (!this.$taskDock || this.tasks.length === 0) return; // 获取当前正在运行的任务,或者最后一个任务 const currentTask = this.tasks.find((t) => t.status === "running") || this.tasks[this.tasks.length - 1]; if (currentTask) { const totalProgress = this.tasks.reduce((sum, task) => sum + task.progress, 0) / this.tasks.length; const taskTitle = currentTask.type === "upload" ? this.language.batchUpload : this.language.batchDownload; const taskTotal = this.language.taskTotal; const taskSuccess = this.language.taskSuccess; const taskFail = this.language.taskFail; this.$taskDock.find(".dock-title").text(taskTitle); this.$taskDock .find(".progress-line .count") .text( `${this.taskStats.success + this.taskStats.fail} / ${this.taskStats.total}`, ); this.$taskDock .find(".progress-line .percent") .text(`${Math.round(totalProgress)}%`); this.$taskDock.find(".task-name").text(currentTask.name); this.$taskDock .find(".bar .bar-inner") .css("width", `${currentTask.progress}%`); this.$taskDock .find(".task-result") .text( `${taskTotal}: ${this.taskStats.total} | ${taskSuccess}: ${this.taskStats.success} | ${taskFail}: ${this.taskStats.fail}`, ); } }, // ============================================================================ // UI Lock 功能 // ============================================================================ /** * 锁定UI */ lockUI: function () { if (this.$uiLock) { this.$uiLock.show(); } }, /** * 解锁UI */ unlockUI: function () { if (this.$uiLock) { this.$uiLock.hide(); } }, /** * 检查UI是否被锁定 * @returns {boolean} 是否被锁定 */ isUILocked: function () { return this.$uiLock && this.$uiLock.is(":visible"); }, // ============================================================================ // 操作菜单相关方法 // ============================================================================ /** * 关闭操作菜单 */ closeActionMenu: function () { this.actionMenuState.open = false; this.actionMenuState.fileId = null; this.actionMenuState.anchorEl = null; this.actionMenuState.items = []; this.renderActionMenu(); }, /** * 打开操作菜单 * @param {jQuery} $el - 触发元素 * @param {Object} file - 文件对象 */ openActionMenu: function ($el, file) { var items = (this.options.getActionMenu && this.options.getActionMenu(file)) || []; this.actionMenuState = { open: true, fileId: file.id, anchorEl: $el[0], items: items, }; this.renderActionMenu(); }, // ============================================================================ // 权限相关方法 // ============================================================================ /** * 权限判断(唯一入口) * @param {Object} file - 文件对象 * @param {string} action - 操作类型 * @returns {boolean} 是否有权限 */ hasPermission: function (file, action) { var globalPermission = this.options.globalPermission; var filePermissionResolver = this.options.filePermissionResolver; // 1. 优先判断单文件权限解析函数 if (typeof filePermissionResolver === "function") { var fileResult = filePermissionResolver({ file: file, action: action }); if (fileResult !== undefined) { return !!fileResult; } } // 2. 判断全局权限 if (typeof globalPermission === "function") { return !!globalPermission({ file: file, action: action }); } else if (typeof globalPermission === "object") { return !!globalPermission[action]; } // 默认返回 true return true; }, /** * 更新权限配置 * @param {Object} config - 权限配置 */ updatePermissions: function (config) { if (!config) return; if (config.globalPermission !== undefined) { this.options.globalPermission = config.globalPermission; } if (config.filePermissionResolver !== undefined) { this.options.filePermissionResolver = config.filePermissionResolver; } this.updateUI(); }, // ============================================================================ // 其他方法 // ============================================================================ /** * 根据ID获取文件 * @param {string} id - 文件ID * @returns {Object} 文件对象 */ getFileById: function (id) { return this.files.find(function (f) { return f.id === id; }); }, /** * 获取选中文件 * @returns {Array} 选中文件数组 */ getSelectedFiles: function () { return this.files.filter((file) => this.selectedFiles.has(file.id)); }, /** * 设置文件 * @param {Array} files - 文件数组 * @returns {G3File} 实例 */ setFiles: function (files) { var self = this; this.files = files.map(function (file) { return { id: file.id || self.generateUniqueId(), name: file.name, size: file.size, uploadDate: file.uploadDate || new Date().toISOString(), url: file.url || "", file: file.file || null, status: file.status || "uploaded", }; }); this.selectedFiles.clear(); this.renderFileList(); this.updateUI(); return this; }, /** * 更新状态 * @param {Object} partialState - 部分状态 * @returns {G3File} 实例 */ updateState: function (partialState) { if (partialState.files) { this.setFiles(partialState.files); } else { // 其他状态更新时清理任务 this.clearTasks(); } if (partialState.selectedFiles) { this.selectedFiles = new Set(partialState.selectedFiles); this.updateUI(); } if ( partialState.globalPermission || partialState.filePermissionResolver ) { this.updatePermissions({ globalPermission: partialState.globalPermission, filePermissionResolver: partialState.filePermissionResolver, }); } return this; }, /** * 销毁 */ destroy: function () { this.$container.off(); // 清理ObjectURL this.objectUrls.forEach((url) => { try { URL.revokeObjectURL(url); } catch (e) { console.warn("释放ObjectURL失败:", e); } }); this.objectUrls.clear(); // 清理引用 this.files = []; this.selectedFiles.clear(); this.$container.empty(); console.log("G3File 组件已销毁"); }, }; // ============================================================================ // jQuery 插件封装 // ============================================================================ $.fn.G3File = function (options) { var args = Array.prototype.slice.call(arguments, 1); var instance; var methodResult; // 存储方法调用结果 this.each(function () { var $this = $(this); instance = $this.data("G3File"); if (!instance) { instance = new G3File(this, options); $this.data("G3File", instance); } else if (typeof options === "string") { var method = options; if (instance[method]) { // 保存方法调用结果 methodResult = instance[method].apply(instance, args); } } }); return instance || this; }; })(jQuery);