(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.currentTask = null; // UI锁定状态 this.uiLocked = false; // 阿里云 OSS 凭证缓存 this.aliyunKeyCache = null; // 预览相关状态 this.previewIndex = 0; this.init(); } // ============================================================================ // 原型方法 // ============================================================================ G3File.prototype = { // ============================================================================ // 配置和常量 // ============================================================================ /** * 默认配置 */ defaults: { /** * 初始化时的文件数据 * @typedef {Object} FileItem * @property {string} _id - 文件唯一标识符 * @property {string} name - 文件名 * @property {number} size - 文件大小(字节) * @property {string} uploadDate - 上传日期(ISO格式) * @property {string} url - 文件访问URL * @property {File|null} file - 本地File对象(仅待上传文件有) * @property {string} status - 文件状态:pending/uploading/uploaded/failed * @type {FileItem[]} */ data: [], mode: "aliyun", 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: { // 阿里云配置是可选的 // getAliyunKey 是必须的,用于动态获取阿里云 OSS 上传凭证 getAliyunKey: null, // 异步函数,返回 { accessid, callback, dir, expire, host, policy, signature } }, 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: "预览", filePreview: "文件预览", fileName: "文件名", fileSize: "文件大小", uploadDate: "上传时间", fileStatus: "状态", notUploaded: "未上传", uploaded: "已上传", cannotPreview: "该类型文件不支持预览", previewCount: "{current}/{total}", uploadToPreview: "请上传后预览", downloadingStatus: "下载中:", downloadingFile: "正在下载:", downloadComplete: "下载完成", downloadResult: "共{total}文件,{success}成功,{failed}失败", close: "关闭", batchDownload: "批量下载", batchUpload: "批量上传", error: "错误", downloadError: "下载失败,请检查文件是否有效", toast: { success: "成功", error: "错误", info: "提示", warning: "警告", }, task: { total: "总计", success: "成功", failed: "失败", currentItem: "当前项", close: "关闭", }, }, "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", filePreview: "File Preview", fileName: "File Name", fileSize: "File Size", uploadDate: "Upload Date", fileStatus: "Status", notUploaded: "Not uploaded", uploaded: "Uploaded", cannotPreview: "This file type is not previewable", previewCount: "{current}/{total}", uploadToPreview: "Please upload to preview", downloadingStatus: "Downloading:", downloadingFile: "Downloading:", downloadComplete: "Download complete", downloadResult: "Total {total} files, {success} succeeded, {failed} failed", close: "Close", batchDownload: "Batch Download", batchUpload: "Batch Upload", error: "Error", downloadError: "Download failed, please check if the file is valid", toast: { success: "Success", error: "Error", info: "Info", warning: "Warning", }, task: { total: "Total", success: "Success", failed: "Failed", currentItem: "Current", close: "Close", }, }, }, /** * 静态图标映射 */ staticIconMap: { download: ` `, delete: ` `, select: ` `, upload: ` `, preview: ` `, more: ` `, }, // ============================================================================ // 初始化和核心方法 // ============================================================================ /** * 初始化 * @returns {G3File} 实例 */ init: function () { var self = this; self.render(); self.bindEvents(); self.updateUI(); if (self.options.data && self.options.data.length) { self.setFiles(self.options.data); } self.preloadAliyunKey(); return this; }, /** * 预加载阿里云 OSS 凭证(初始化时调用) */ preloadAliyunKey: async function () { if (this.options.mode !== "aliyun") { return; } const aliyunConfig = this.options.uploadConfig.aliyun; if (!aliyunConfig || typeof aliyunConfig.getAliyunKey !== "function") { return; } try { const keyData = await aliyunConfig.getAliyunKey(); if (keyData && keyData.accessid && keyData.host) { this.aliyunKeyCache = { data: keyData, expireTime: keyData.expire ? keyData.expire * 1000 : Date.now() + 3600000, }; console.log("[G3File] 阿里云 OSS 凭证预加载成功"); } } catch (error) { console.warn("[G3File] 阿里云 OSS 凭证预加载失败:", error); } }, /** * 检查并获取有效的阿里云 OSS 凭证 * @returns {Object|null} 阿里云凭证数据 */ getValidAliyunKey: async function () { const now = Date.now(); if (this.aliyunKeyCache) { if (now < this.aliyunKeyCache.expireTime - 60000) { return this.aliyunKeyCache.data; } console.log("[G3File] 阿里云 OSS 凭证即将过期,自动刷新"); } const aliyunConfig = this.options.uploadConfig.aliyun; if (!aliyunConfig || typeof aliyunConfig.getAliyunKey !== "function") { throw new Error("阿里云上传配置不完整,缺少 getAliyunKey 函数"); } try { const keyData = await aliyunConfig.getAliyunKey(); if (!keyData || !keyData.accessid || !keyData.host) { throw new Error("阿里云凭证获取失败"); } this.aliyunKeyCache = { data: keyData, expireTime: keyData.expire ? keyData.expire * 1000 : Date.now() + 3600000, }; console.log("[G3File] 阿里云 OSS 凭证获取成功"); return keyData; } catch (error) { console.error("[G3File] 获取阿里云 OSS 凭证失败:", error); throw error; } }, /** * 生成文件 key(UUID + 原扩展名) * @param {Object} keyData - 阿里云凭证数据 * @param {string} filename - 原始文件名 * @returns {string} 生成的 key */ generateFileKey: function (keyData, filename) { const dir = keyData.dir || ""; const ext = filename.slice(filename.lastIndexOf(".")) || ""; const uuid = this.generateUniqueId().replace("file_", ""); return dir + uuid + ext; }, // ============================================================================ // 工具方法 // ============================================================================ /** * 生成唯一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) { var numBytes = typeof bytes === 'string' ? parseFloat(bytes) : bytes; if (numBytes === null || numBytes === undefined || isNaN(numBytes) || numBytes <= 0) { return "-"; } var k = 1024; var sizes = ["B", "KB", "MB", "GB", "TB"]; var i = Math.floor(Math.log(numBytes) / Math.log(k)); i = Math.max(0, Math.min(i, sizes.length - 1)); return (numBytes / 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"); } let fetchUrl = url; if (this.options.mode === "aliyun" && this.aliyunKeyCache) { const { accessid, policy, signature } = this.aliyunKeyCache.data; if (accessid && policy && signature) { const separator = url.includes("?") ? "&" : "?"; fetchUrl = `${url}${separator}OSSAccessKeyId=${accessid}&policy=${policy}&Signature=${signature}`; } } const res = await fetch(fetchUrl); 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} ${this.language.selectFiles} 0/0 ${this.language.selectFiles} ${this.language.emptyTip1} `; 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"); // 预览相关DOM引用 this.$previewPanel = this.$container.find(".g3-file-preview-panel"); this.$previewToolbar = this.$container.find(".g3-preview-toolbar"); this.$previewContent = this.$container.find(".g3-preview-content"); this.$btnPrev = this.$container.find(".preview-prev"); this.$btnNext = this.$container.find(".preview-next"); this.$previewName = this.$container.find(".preview-name"); this.$previewCounter = this.$container.find(".preview-counter"); 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); 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 downloadDisabled = !this.hasPermission(file, "download"); const previewDisabled = !this.hasPermission(file, "view"); 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; } return ` ${content} ${statusBadge} ${this.staticIconMap.delete} ${file.name} ${typeof file.size === 'string' ? (file.size || '-') : 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); }); // 更新预览区域 self.updatePreview(); }, /** * 获取已上传的文件列表(仅status为uploaded的文件) */ getUploadedFiles: function () { return this.files; }, isPreviewableFile: function (fileName) { var nonPreviewableExts = [ ".rar", ".jar", ".exe", ".zip", ".7z", ".tar", ".gz", ".bz2", ".iso", ".dll", ".bin", ".msi", ".cab", ".rpm", ".deb", ".apk", ".ipa", ".dat", ".bak", ".tmp", ".log", ".cache", ".db", ".sqlite" ]; var ext = fileName.toLowerCase().substring(fileName.lastIndexOf('.')); return !nonPreviewableExts.includes(ext); }, /** * 预览上一个文件 */ previewPrev: function () { var uploadedFiles = this.getUploadedFiles(); if (uploadedFiles.length === 0) return; if (this.previewIndex > 0) { this.previewIndex--; } else { this.previewIndex = uploadedFiles.length - 1; } this.updatePreview(); }, /** * 预览下一个文件 */ previewNext: function () { var uploadedFiles = this.getUploadedFiles(); if (uploadedFiles.length === 0) return; if (this.previewIndex < uploadedFiles.length - 1) { this.previewIndex++; } else { this.previewIndex = 0; } this.updatePreview(); }, base64Encode: function (str) { return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function (match, p1) { return String.fromCharCode('0x' + p1); })); }, /** * 更新预览区域显示 */ updatePreview: function () { var allFiles = this.getUploadedFiles(); var total = allFiles.length; if (total === 0) { this.$previewContent.html(` 请选择文件 `); this.$previewName.text(this.language.selectFiles); this.$previewCounter.text("0/0"); this.$btnPrev.prop("disabled", true); this.$btnNext.prop("disabled", true); return; } if (this.previewIndex >= total) { this.previewIndex = total - 1; } if (this.previewIndex < 0) { this.previewIndex = 0; } var currentFile = allFiles[this.previewIndex]; var isUploaded = currentFile.status === "uploaded"; var isPreviewable = this.isPreviewableFile(currentFile.name); var fileUrl = currentFile.url && currentFile.url !== '' ? currentFile.url : null; this.$btnPrev.prop("disabled", this.previewIndex === 0); this.$btnNext.prop("disabled", this.previewIndex === total - 1); this.$previewName.text(currentFile.name); var counterText = this.language.previewCount.replace("{current}", this.previewIndex + 1).replace("{total}", total); this.$previewCounter.text(counterText); var previewContent = ""; if (isUploaded && fileUrl && isPreviewable) { var previewUrl = this.options.previewUrl || ''; var fullPreviewUrl = previewUrl ? previewUrl + this.base64Encode(fileUrl) : fileUrl; previewContent = ` `; } else { var tipsHtml = ''; if (!isUploaded) { tipsHtml = `请上传后预览`; } else if (!isPreviewable) { tipsHtml = `请下载后预览`; } else if (!fileUrl) { tipsHtml = `链接无效请检查链接`; } previewContent = ` ${currentFile.name} ${tipsHtml} `; } this.$previewContent.html(previewContent); }, setPreviewFile: function (fileId) { var uploadedFiles = this.getUploadedFiles(); var index = uploadedFiles.findIndex(function (f) { return f._id === fileId; }); if (index >= 0) { this.previewIndex = index; this.updatePreview(); } }, // ============================================================================ // 事件绑定相关方法 // ============================================================================ /** * 绑定事件 */ 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); } }); // 预览上一个 this.$btnPrev.off("click").on("click", function () { self.previewPrev(); }); // 预览下一个 this.$btnNext.off("click").on("click", function () { self.previewNext(); }); }, /** * 绑定文件项事件 */ 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; self.setPreviewFile(id); 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]); } self.setPreviewFile(id); }); // 更多按钮 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); }); }, // ============================================================================ // 文件处理相关方法 // ============================================================================ /** * 处理文件选择 * @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.showToast({ title: this.language.error || "错误", message: this.language.downloadError || "下载失败,请检查文件是否有效", type: "error", duration: 3000, }); 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.showToast({ title: this.language.error || "错误", message: this.language.downloadError || "下载失败,请检查文件是否有效", type: "error", duration: 3000, }); return; } saveAs(blob, file.name || "未命名文件"); } catch (err) { console.error("文件下载失败:", err); this.showToast({ title: this.language.error || "错误", message: this.language.downloadError || "下载失败,请检查文件是否有效", type: "error", duration: 3000, }); } }, /** * 内部批量下载 * @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 successCount = 0; let failedCount = 0; const concurrency = 3; const taskId = this.generateUniqueId(); this.startTask({ id: taskId, title: this.language.batchDownload || "批量下载", total: total, current: 0, currentItem: "", }); const downloadOne = async (item) => { try { this.updateTask({ id: taskId, current: successCount + failedCount, currentItem: item.name, }); 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++; return; } const uniqueFileName = this.getUniqueFileName(item.name, addedFiles); zip.file(uniqueFileName, content); addedFiles.add(uniqueFileName); successCount++; } catch (err) { console.warn("跳过失败文件:", item.name, err); failedCount++; } }; try { const filesCopy = [...files]; const runner = async () => { while (filesCopy.length) { const item = filesCopy.shift(); await downloadOne(item); } }; await Promise.all(Array.from({ length: concurrency }, runner)); this.updateTask({ id: taskId, current: total, }); 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"); this.finishTask({ id: taskId, total: total, success: successCount, failed: failedCount, }); if (failedCount === 0) { this.showToast({ title: this.language.downloadComplete || "下载完成", message: `${successCount}/${total}`, type: "success", duration: 3000, }); } else { this.showToast({ title: this.language.downloadComplete || "下载完成", message: `${this.language.downloadResult || "共{total}文件,{success}成功,{failed}失败"}` .replace("{total}", total) .replace("{success}", successCount) .replace("{failed}", failedCount), type: failedCount === total ? "error" : "warning", duration: 5000, }); } } catch (err) { console.error("批量下载失败:", err); failedCount = total - successCount; this.finishTask({ id: taskId, total: total, success: successCount, failed: failedCount, }); this.showToast({ title: this.language.error || "错误", message: this.language.downloadError || "下载失败,请检查文件是否有效", type: "error", duration: 5000, }); } }, /** * 内部删除 * @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(); } }, /** * 内部上传方法,支持阿里云 OSS 直传 * @param {Object} file - 文件对象 * @param {Function} callback - 上传完成回调 */ internalUpload: async function (file, callback) { const self = this; try { const keyData = await this.getValidAliyunKey(); const key = this.generateFileKey(keyData, file.name); const formData = new FormData(); formData.append("OSSAccessKeyId", keyData.accessid); formData.append("policy", keyData.policy); formData.append("signature", keyData.signature); formData.append("key", key); formData.append("callback", keyData.callback); formData.append("success_action_status", "200"); formData.append("file", file.file || file); const response = await fetch(keyData.host, { method: "POST", body: formData, }); if (!response.ok) { throw new Error(`上传失败,HTTP 状态码: ${response.status}`); } const result = await response.json(); const uploadedFile = { ...file, status: "uploaded", url: result.url || `${keyData.host}/${key}`, 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); } catch (error) { console.error("[G3File] 上传失败:", 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; } const total = pendingFiles.length; const taskId = this.generateUniqueId(); // 启动上传任务 this.startTask({ id: taskId, title: this.language.batchUpload || "批量上传", total: total, current: 0, currentItem: "", }); // 逐个上传文件 pendingFiles.forEach((file) => { // 更新文件状态为上传中 self.updateFileStatus(file._id, "uploading"); if (typeof customUpload === "function") { // 使用自定义上传方法 customUpload(file) .then((uploadedFile) => { // 更新任务进度 self.updateTask({ id: taskId, current: successCount + failCount, currentItem: file.name, }); // 更新文件状态 self.updateFileStatus(file._id, "uploaded", uploadedFile); // 触发上传成功回调 if (typeof self.options.onUploadSuccess === "function") { self.options.onUploadSuccess(uploadedFile); } successCount++; checkComplete(); }) .catch((error) => { // 更新任务进度 self.updateTask({ id: taskId, current: successCount + failCount, currentItem: file.name, }); // 更新文件状态为失败 self.updateFileStatus(file._id, "failed"); // 触发上传失败回调 if (typeof self.options.onUploadError === "function") { self.options.onUploadError(file, error); } failCount++; checkComplete(); }); } else if (this.options.mode === "aliyun") { // 使用阿里云内部上传方法 self.internalUpload(file, (error, uploadedFile) => { // 更新任务进度 self.updateTask({ id: taskId, current: successCount + failCount, currentItem: file.name, }); if (error) { failCount++; } else { successCount++; } checkComplete(); }); } }); // 检查是否所有文件都上传完成 function checkComplete() { if (successCount + failCount === pendingFiles.length) { // 完成任务 self.finishTask({ id: taskId, total: total, success: successCount, failed: failCount, }); // 触发上传完成回调 if (typeof self.options.onUploadComplete === "function") { self.options.onUploadComplete(successCount, failCount); } } } }, // ============================================================================ // Toast 提示系统 // ============================================================================ /** * Toast 图标映射 */ toastIcons: { success: ``, error: ``, info: ``, warning: ``, }, /** * 显示 Toast 提示 * @param {Object} options - Toast 配置 * @param {string} options.title - 标题 * @param {string} options.message - 消息内容 * @param {string} options.type - 类型:success/error/info/warning * @param {number} options.duration - 显示时长(毫秒),默认 3000 */ showToast: function (options) { const { title, message, type = "info", duration = 3000 } = options; const self = this; const toastContainer = this.$container.find(".g3-toast-container"); if (!toastContainer.length) { this.$container.append(''); this.$toastContainer = this.$container.find(".g3-toast-container"); } const toastId = "toast_" + this.generateUniqueId(); const toastType = this.language.toast[type] || type; const icon = this.toastIcons[type] || this.toastIcons.info; const toastHtml = ` ${icon} ${toastType} ${message ? `${message}` : ""} × `; this.$toastContainer.append(toastHtml); const $toast = this.$container.find(`#${toastId}`); $toast.find(".toast-close").off("click").on("click", function () { self.removeToast(toastId); }); setTimeout(function () { self.removeToast(toastId); }, duration); const maxToasts = 5; const toasts = this.$toastContainer.find(".g3-toast"); if (toasts.length > maxToasts) { toasts.first().remove(); } }, /** * 移除 Toast * @param {string} toastId - Toast ID */ removeToast: function (toastId) { const $toast = this.$container.find(`#${toastId}`); if ($toast.length) { $toast.addClass("closing"); setTimeout(function () { $toast.remove(); }, 300); } }, // ============================================================================ // Task Center 任务中心系统 // ============================================================================ /** * 创建并启动任务 * @param {Object} options - 任务配置 * @param {string} options.id - 任务ID * @param {string} options.title - 任务标题(国际化key或文本) * @param {number} options.total - 总数量 * @param {number} options.current - 当前进度 * @param {string} options.currentItem - 当前处理项 */ startTask: function (options) { const { id, title, total = 0, current = 0, currentItem = "" } = options; this.currentTask = { id: id || this.generateUniqueId(), title: title, total: total, current: current, currentItem: currentItem, success: 0, failed: 0, status: "running", }; this.lockUI(); this.renderTaskCenter(); }, /** * 更新任务进度 * @param {Object} options - 更新配置 * @param {string} options.id - 任务ID * @param {number} options.current - 当前进度 * @param {string} options.currentItem - 当前处理项 */ updateTask: function (options) { const { id, current, currentItem } = options; if (!this.currentTask || this.currentTask.id !== id) { return; } if (current !== undefined) { this.currentTask.current = current; } if (currentItem !== undefined) { this.currentTask.currentItem = currentItem; } this.updateTaskCenter(); }, /** * 完成任务 * @param {Object} options - 完成配置 * @param {string} options.id - 任务ID * @param {number} options.total - 总数量 * @param {number} options.success - 成功数量 * @param {number} options.failed - 失败数量 */ finishTask: function (options) { const { id, total, success = 0, failed = 0 } = options; if (!this.currentTask || this.currentTask.id !== id) { return; } this.currentTask.status = "finished"; this.currentTask.total = total !== undefined ? total : this.currentTask.total; this.currentTask.success = success; this.currentTask.failed = failed; this.currentTask.current = this.currentTask.total; this.unlockUI(); this.updateTaskCenter(); }, /** * 手动关闭任务 UI * @param {string} id - 任务ID */ closeTask: function (id) { if (this.currentTask && this.currentTask.id === id) { this.$taskCenter.removeClass("is-visible"); this.currentTask = null; } }, /** * 渲染任务中心 UI */ renderTaskCenter: function () { const task = this.currentTask; if (!task) return; let taskCenter = this.$container.find(".g3-task-center"); if (!taskCenter.length) { this.$container.append(` ${task.title} × ${this.language.task.total} ${this.language.task.success} ${this.language.task.failed} ${this.language.task.close} `); taskCenter = this.$container.find(".g3-task-center"); const self = this; taskCenter.find(".task-close, .complete-button").off("click").on("click", function () { self.closeTask(self.currentTask?.id); }); } this.$taskCenter = taskCenter; this.$taskCenter.addClass("is-visible"); this.updateTaskCenter(); }, /** * 更新任务中心 UI */ updateTaskCenter: function () { const task = this.currentTask; if (!task || !this.$taskCenter) return; const progress = task.total > 0 ? ((task.current / task.total) * 100).toFixed(0) : 0; this.$taskCenter.find(".task-title").text(task.title); this.$taskCenter.find(".current-item").text( task.currentItem ? `${this.language.task.currentItem}: ${task.currentItem}` : "" ); this.$taskCenter.find(".progress-inner").css("width", `${progress}%`); this.$taskCenter.find(".progress-text").text(`${task.current} / ${task.total}`); if (task.status === "finished") { this.$taskCenter.find(".result-section").show(); this.$taskCenter.find(".complete-button").show(); this.$taskCenter.find(".result-item:first .result-value").text(task.total); this.$taskCenter.find(".result-success .result-value").text(task.success); this.$taskCenter.find(".result-failed .result-value").text(task.failed); } else { this.$taskCenter.find(".result-section").hide(); this.$taskCenter.find(".complete-button").hide(); } }, // ============================================================================ // UI Lock 机制 // ============================================================================ /** * 锁定 UI */ lockUI: function () { this.uiLocked = true; let uiLock = this.$container.find(".g3-ui-lock"); if (!uiLock.length) { this.$container.append(''); uiLock = this.$container.find(".g3-ui-lock"); } this.$uiLock = uiLock; this.$uiLock.addClass("is-locked"); }, /** * 解锁 UI */ unlockUI: function () { this.uiLocked = false; this.$uiLock?.removeClass("is-locked"); }, // ============================================================================ // 操作菜单相关方法 // ============================================================================ /** * 关闭操作菜单 */ 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)); }, /** * 获取所有文件 * @returns {Array} 文件数组 */ getFiles: function () { return this.files; }, /** * 设置文件 * @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", ...file, }; }); 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 { // 其他状态更新时关闭当前任务 if (this.currentTask) { this.closeTask(this.currentTask.id); } } 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);
${this.language.emptyTip1}
请选择文件
请上传后预览
请下载后预览
链接无效请检查链接
${currentFile.name}