diff --git a/code/file-preview/.trae/documents/task_fix_plan.md b/code/file-preview/.trae/documents/task_fix_plan.md new file mode 100644 index 00000000..fbd5be70 --- /dev/null +++ b/code/file-preview/.trae/documents/task_fix_plan.md @@ -0,0 +1,45 @@ +# Task 关闭按钮修复计划 + +## 问题分析 + +根据代码审查,`g3-task-close` 点击无反应的核心问题在于: + +1. **事件绑定方式错误**:使用直接绑定而非事件委托 +2. **taskId 未绑定到 DOM**:无法通过 DOM 获取当前任务 ID +3. **closeTask 方法不完善**:只删除数据,未删除对应的 DOM 元素 + +## 修复方案 + +### 1. 修改 renderTaskCenter 方法 +- 在 DOM 元素上添加 `data-task-id` 属性 +- 移除直接的事件绑定 + +### 2. 修改 closeTask 方法 +- 同时删除数据和对应的 DOM 元素 + +### 3. 添加事件委托(在初始化时绑定) +- 使用事件委托绑定 `.g3-task-close` 点击事件 + +### 4. 检查 pointer-events 样式 +- 确保 `.g3-task-close` 按钮可点击 + +## 修改文件 + +- `d:\workspace\code\file-preview\g3-file\index.js` + +## 修改步骤 + +1. 在 `renderTaskCenter` 的 HTML 模板中添加 `data-task-id` +2. 移除 `renderTaskCenter` 中的直接事件绑定 +3. 修改 `closeTask` 方法,添加 DOM 删除逻辑 +4. 在初始化时添加事件委托绑定 + +## 风险评估 + +- 低风险:修改局部代码,不影响其他功能 +- 需确保事件委托只绑定一次 + +## 验证方式 + +1. 点击关闭按钮,检查控制台是否输出正确的 taskId +2. 确认任务数据和 UI 都被正确移除 \ No newline at end of file diff --git a/code/file-preview/g3-file/.trae/documents/optimize-g3file-plan.md b/code/file-preview/g3-file/.trae/documents/optimize-g3file-plan.md new file mode 100644 index 00000000..6d9c7e00 --- /dev/null +++ b/code/file-preview/g3-file/.trae/documents/optimize-g3file-plan.md @@ -0,0 +1,425 @@ +# G3File 组件结构优化和缺陷修复计划 + +## 一、P0 必须修复的问题 + +### 1. 避免整列表重渲染(核心性能问题) + +**问题**:`updateFileStatus` 调用 `renderFileList()`,导致整个列表重新渲染 + 重新绑定事件 + +**解决方案**:新增 `updateFileItem(fileId)` 方法,仅更新单个文件项 + +```javascript +updateFileItem: function (fileId) { + var file = this.getFileById(fileId); + var $item = this.$fileList.find(`.g3-file-item[data-id="${fileId}"]`); + if (!file || !$item.length) return; + + // 更新 checkbox + $item.find(".g3-file-checkbox").prop("checked", this.selectedFiles.has(fileId)); + + // 更新 badge + var $badge = $item.find(".g3-status-badge"); + if (file.status === "pending") { + $badge.attr("class", "g3-status-badge g3-status-pending").text(this.language.pending); + } else if (file.status === "uploading") { + $badge.attr("class", "g3-status-badge g3-status-uploading").text(this.language.uploading); + } else if (file.status === "failed") { + $badge.attr("class", "g3-status-badge g3-status-failed").text(this.language.uploadFailed); + } else if (file.status === "uploaded") { + $badge.attr("class", "g3-status-badge g3-status-uploaded").text(this.language.uploaded); + } + + // 更新权限状态 + var canDownload = this.hasPermission(file, "download"); + var canView = this.hasPermission(file, "view"); + var canDelete = this.hasPermission(file, "delete"); + $item.find(".g3-action-download").toggleClass("is-disabled", !canDownload); + $item.find(".g3-action-preview").toggleClass("is-disabled", !canView); + $item.find(".g3-file-delete-btn").toggleClass("is-disabled", !canDelete); +} +``` + +**修改点**: +- `updateFileStatus` 方法中移除 `renderFileList()` 调用,改为 `updateFileItem(fileId)` + +--- + +### 2. 预览逻辑设计缺陷(previewIndex 问题) + +**问题**:使用 `previewIndex` 作为预览定位,删除/新增文件会导致错位 + +**解决方案**:改为使用 `currentPreviewFileId` + +**修改点**: +1. 构造函数中:`this.previewIndex = 0` → `this.currentPreviewFileId = null` +2. 新增方法 `getPreviewFileIndex()` 用于兼容 prev/next 导航 +3. `previewPrev()` 和 `previewNext()` 改为基于 `currentPreviewFileId` 计算 +4. `updatePreview()` 改为使用 `currentPreviewFileId` 获取文件 +5. `setPreviewFile()` 直接设置 `currentPreviewFileId` +6. `setFiles()` 中:`this.previewIndex = 0` → `this.currentPreviewFileId = null` + +--- + +### 3. Task Center 多任务支持 + +**问题**:`this.currentTask` 只能存在一个任务,上传/下载会互相覆盖 + +**解决方案**:改为 `this.tasks = new Map()` 多任务模型 + +```javascript +startTask: function (options) { + var task = { + id: options.id || G3FileUtils.generateUniqueId(), + title: options.title, + total: options.total || 0, + current: options.current || 0, + currentItem: options.currentItem || "", + success: 0, + failed: 0, + status: "running" + }; + this.tasks.set(task.id, task); + this.renderTaskCenter(task.id); + this.lockUI(); + return task.id; +} + +updateTask: function (options) { + var task = this.tasks.get(options.id); + if (!task) return; + if (options.current !== undefined) task.current = options.current; + if (options.currentItem !== undefined) task.currentItem = options.currentItem; + this.updateTaskCenter(options.id); +} + +finishTask: function (options) { + var task = this.tasks.get(options.id); + if (!task) return; + task.status = "finished"; + task.total = options.total !== undefined ? options.total : task.total; + task.success = options.success || 0; + task.failed = options.failed || 0; + task.current = task.total; + this.updateTaskCenter(options.id); + this.tasks.delete(options.id); + if (this.tasks.size === 0) { + this.unlockUI(); + } +} +``` + +**修改点**: +- `this.currentTask` → `this.tasks = new Map()` +- `renderTaskCenter(taskId)` 和 `updateTaskCenter(taskId)` 接收 taskId 参数 +- `closeTask(id)` 从 `this.tasks` 中删除任务 + +--- + +## 二、P1 重要优化 + +### 4. 事件绑定优化 + +**问题**:每次 `renderFileList` 后都调用 `bindFileItemEvents` 重新绑定 + +**解决方案**:使用事件委托,在 `bindEvents` 中统一绑定一次 + +```javascript +bindEvents: function () { + var self = this; + + // 文件列表事件委托(只在 init 时绑定一次) + this.$fileList + .on("click", ".g3-action-preview", function (e) { ... }) + .on("click", ".g3-action-download", function (e) { ... }) + .on("change", ".g3-file-checkbox", function (e) { ... }) + .on("click", ".g3-file-delete-btn", function (e) { ... }) + .on("click", ".g3-file-item", function (e) { ... }) + .on("click", ".g3-action-more", function (e) { ... }); + + // 其他事件保持不变... +} +``` + +**修改点**: +- `renderFileList()` 中移除 `bindFileItemEvents()` 调用 +- `bindFileItemEvents()` 方法可移除或保留(如果外部需要单独调用) + +--- + +### 5. 上传并发控制 + +**问题**:`handleUpload` 中 forEach 并发无限制 + +**解决方案**:使用 async pool 实现并发控制 + +```javascript +handleUpload: function (files) { + var self = this; + var concurrency = 3; + var pendingFiles = files.filter((file) => file.status === "pending"); + if (pendingFiles.length === 0) return; + + var total = pendingFiles.length; + var taskId = this.startTask({ + title: this.language.batchUpload || "批量上传", + total: total + }); + + var successCount = 0; + var failCount = 0; + var index = 0; + + var uploadOne = async function () { + while (index < pendingFiles.length) { + var file = pendingFiles[index++]; + self.updateFileStatus(file._id, "uploading"); + + try { + var uploadedFile = await self.internalUpload(file); + successCount++; + self.updateFileStatus(file._id, "uploaded", uploadedFile); + if (typeof self.options.onUploadSuccess === "function") { + self.options.onUploadSuccess(uploadedFile); + } + } catch (error) { + failCount++; + self.updateFileStatus(file._id, "failed"); + if (typeof self.options.onUploadError === "function") { + self.options.onUploadError(file, error); + } + } + + self.updateTask({ + id: taskId, + current: successCount + failCount, + currentItem: file.name + }); + } + }; + + Promise.all(Array.from({ length: concurrency }, uploadOne)).then(function () { + self.finishTask({ id: taskId, total: total, success: successCount, failed: failCount }); + if (typeof self.options.onUploadComplete === "function") { + self.options.onUploadComplete(successCount, failCount); + } + }); +} +``` + +**修改点**: +- `handleUpload` 方法重写,实现并发控制 + +--- + +### 6. ObjectURL 生命周期管理 + +**问题**:组件销毁时未释放 ObjectURL + +**解决方案**:`destroy` 方法中统一释放 + +```javascript +destroy: function () { + // 清理事件 + this.$container.off(); + $(document).off(".g3file"); + + // 清理 ObjectURL + this.objectUrls.forEach(function (url) { + try { + URL.revokeObjectURL(url); + } catch (e) { + console.warn("释放ObjectURL失败:", e); + } + }); + this.objectUrls.clear(); + + // 清理文件列表 DOM + this.$fileList.empty(); + + // 销毁 UI + this.$container.find(".g3-global-menu").remove(); + this.$container.find(".g3-toast-container").remove(); + this.$container.find(".g3-task-center").remove(); + this.$container.find(".g3-ui-lock").remove(); + + // 重置状态 + this.files = []; + this.selectedFiles.clear(); + this.tasks.clear(); + this.currentPreviewFileId = null; + + console.log("G3File 组件已销毁"); +} +``` + +--- + +### 7. 权限系统统一入口 + +**问题**:globalPermission 和 filePermissionResolver 分散使用 + +**解决方案**:新增 `resolvePermission(file, action)` 统一方法 + +```javascript +resolvePermission: function (file, action) { + var globalPermission = this.options.globalPermission; + var filePermissionResolver = this.options.filePermissionResolver; + + if (typeof filePermissionResolver === "function") { + var result = filePermissionResolver({ file: file, action: action }); + if (result !== undefined) return !!result; + } + + if (typeof globalPermission === "function") { + return !!globalPermission({ file: file, action: action }); + } else if (typeof globalPermission === "object") { + return !!globalPermission[action]; + } + + return true; +} +``` + +**修改点**: +- `hasPermission` 重命名为 `resolvePermission` 或新增方法,原方法保留为别名 +- 所有调用 `hasPermission` 的地方改为调用 `resolvePermission` + +--- + +## 三、P2 UI/交互修复 + +### 8. 点击文件项行为优化 + +**问题**:点击整个 file-item 会触发预览,容易误触 + +**解决方案**:改为双击触发预览 + +```javascript +this.$fileList.on("dblclick", ".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.getFileById(id); + if (!file) return; + self.setPreviewFile(id); + if (typeof self.options.onPreview === "function") { + self.options.onPreview([file]); + } +}); +``` + +**修改点**: +- `bindEvents` 中的 `.g3-file-item` 点击事件改为双击 + +--- + +### 9. UI 锁机制完善 + +**解决方案**:在关键操作开始时检查锁状态 + +```javascript +handleUpload: function (files) { + if (this.uiLocked) { + console.warn("UI is locked, please wait..."); + return; + } + // ... 原有逻辑 +} + +internalDelete: function (files) { + if (this.uiLocked) { + console.warn("UI is locked, please wait..."); + return; + } + // ... 原有逻辑 +} +``` + +--- + +### 10. 批量删除安全控制 + +**解决方案**:增加 confirm 回调支持 + +```javascript +internalDelete: function (files) { + var self = this; + + if (typeof this.options.onBeforeDelete === "function") { + var result = this.options.onBeforeDelete(files); + if (result === false) return; + if (typeof result === "function") { + // 异步确认 + result(function (confirmed) { + if (confirmed) self.doDelete(files); + }); + return; + } + } + self.doDelete(files); +} + +doDelete: function (files) { + // 原 internalDelete 逻辑 +} +``` + +**修改点**: +- 新增 `onBeforeDelete` 回调选项 +- 重构删除逻辑 + +--- + +## 四、CSS 优化 + +### 11. 文件列表布局优化 + +**问题**:使用 calc 写死宽度 + +**解决方案**:改为 flex: calc(50% - 26px) 或 grid + +```css +.g3-main { + display: flex; + flex-wrap: wrap; + align-content: flex-start; +} + +.g3-file-item { + flex: 0 0 calc(50% - 20px); + max-width: calc(50% - 20px); +} +``` + +--- + +### 12. z-index 规范化 + +**解决方案**:建立层级规范 + +```css +:root { + --g3-z-dropdown: 100; + --g3-z-modal: 200; + --g3-z-toast: 300; + --g3-z-task: 400; + --g3-z-lock: 500; +} +``` + +--- + +## 五、实施步骤 + +1. **P0-1**: 新增 `updateFileItem()` 方法,修改 `updateFileStatus()` +2. **P0-2**: 替换 `previewIndex` 为 `currentPreviewFileId` +3. **P0-3**: 重构 Task Center 为多任务模型 +4. **P1-4**: 事件委托优化 +5. **P1-5**: 上传并发控制 +6. **P1-6**: 完善 `destroy()` 方法 +7. **P1-7**: 新增 `resolvePermission()` 方法 +8. **P2-8**: 双击预览 +9. **P2-9**: UI 锁检查 +10. **P2-10**: 删除确认回调 +11. **CSS**: 布局优化和 z-index 规范化 diff --git a/code/file-preview/g3-file/index.css b/code/file-preview/g3-file/index.css index 74ea21ab..aa6278ff 100644 --- a/code/file-preview/g3-file/index.css +++ b/code/file-preview/g3-file/index.css @@ -5,6 +5,11 @@ overflow: hidden; border: 1px solid #eef0f2; border-radius: 8px; + --g3-z-dropdown: 100; + --g3-z-modal: 200; + --g3-z-toast: 300; + --g3-z-task: 400; + --g3-z-lock: 500; } .g3-file-container.drag-over { @@ -32,15 +37,13 @@ .g3-main { flex: 1; min-height: 0; - padding: 4px 0px; + padding: 10px; overflow: auto; - display: flex; - flex-wrap: wrap; + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; align-content: flex-start; -} - -.g3-main > * { - margin: 10px; + box-sizing: border-box; } .g3-empty-tip { @@ -143,13 +146,15 @@ } .g3-file-item { - width: calc(50% - 26px); + flex: none; + width: 100%; position: relative; cursor: pointer; border: 1px solid #eef0f2; border-radius: 8px; background: #fff; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + box-sizing: border-box; } .g3-file-item:hover { @@ -379,7 +384,7 @@ align-items: center; justify-content: center; cursor: pointer; - z-index: 100; + z-index: var(--g3-z-dropdown); font-size: 12px; border: 1px solid #e8e8e8; transition: all 0.2s ease; @@ -537,7 +542,7 @@ .g3-global-menu { position: fixed; - z-index: 9999; + z-index: var(--g3-z-dropdown); width: 160px; background: #fff; border: 1px solid #eee; @@ -578,7 +583,7 @@ position: fixed; top: 20px; right: 20px; - z-index: 9999; + z-index: var(--g3-z-toast); display: flex; flex-direction: column; gap: 12px; @@ -734,7 +739,7 @@ border: 1px solid #eef0f2; border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - z-index: 9999; + z-index: var(--g3-z-task); display: none; animation: taskCenterSlideIn 0.3s ease-out; } @@ -857,23 +862,6 @@ color: #ff4d4f; } -.g3-complete-button { - width: 100%; - margin-top: 12px; - padding: 8px; - border: none; - background: #165dff; - color: #fff; - font-size: 13px; - border-radius: 4px; - cursor: pointer; - transition: all 0.2s ease; -} - -.g3-complete-button:hover { - filter: brightness(1.1); -} - .g3-ui-lock { position: fixed; top: 0; @@ -881,7 +869,7 @@ right: 0; bottom: 0; background: rgba(0, 0, 0, 0.05); - z-index: 9998; + z-index: var(--g3-z-lock); display: none; } diff --git a/code/file-preview/g3-file/index.js b/code/file-preview/g3-file/index.js index cbf47bed..2f9ab38e 100644 --- a/code/file-preview/g3-file/index.js +++ b/code/file-preview/g3-file/index.js @@ -9,6 +9,7 @@ this.$container = $(container); this.options = $.extend({}, this.defaults, options); this.instanceId = G3FileUtils.generateUniqueId(); + this.documentNamespace = ".g3file_" + this.instanceId; this.files = []; this.selectedFiles = new Set(); this.objectUrls = new Map(); @@ -24,7 +25,7 @@ }; // 当前任务(单个任务实例) - this.currentTask = null; + this.tasks = new Map(); // UI锁定状态 this.uiLocked = false; @@ -32,8 +33,8 @@ // 阿里云 OSS 凭证缓存 this.aliyunKeyCache = null; - // 预览相关状态 - this.previewIndex = 0; + // 预览相关状态(基于 fileId 定位,避免删除/新增导致错位) + this.currentPreviewFileId = null; this.init(); } @@ -64,6 +65,7 @@ */ data: [], mode: "aliyun", + onBeforeDelete: null, onDelete: null, onDownload: null, onSelect: null, @@ -205,14 +207,6 @@ // 工具方法 // ============================================================================ - /** - * 生成唯一ID - * @returns {string} 唯一ID - */ - generateUniqueId: function () { - return "file_" + Math.random().toString(36).slice(2) + Date.now(); - }, - /** * 获取文件图标 * @param {string} filename - 文件名 @@ -558,7 +552,6 @@ }); this.$fileList.html(html); - this.bindFileItemEvents(); }, /** @@ -620,11 +613,52 @@ }); }, + updateFileItem: function (fileId) { + var file = this.getFileById(fileId); + var $item = this.$fileList.find(`.g3-file-item[data-id="${fileId}"]`); + if (!file || !$item.length) return; + + $item + .find(".g3-file-checkbox") + .prop("checked", this.selectedFiles.has(fileId)); + + var $badge = $item.find(".g3-status-badge"); + if (file.status === "pending") { + $badge + .attr("class", "g3-status-badge g3-status-pending") + .text(this.language.pending) + .show(); + } else if (file.status === "uploading") { + $badge + .attr("class", "g3-status-badge g3-status-uploading") + .text(this.language.uploading) + .show(); + } else if (file.status === "failed") { + $badge + .attr("class", "g3-status-badge g3-status-failed") + .text(this.language.uploadFailed) + .show(); + } else if (file.status === "uploaded") { + $badge.attr("class", "g3-status-badge g3-status-uploaded").hide(); + } + + var canDownload = this.hasPermission(file, "download"); + var canView = this.hasPermission(file, "view"); + var canDelete = this.hasPermission(file, "delete"); + $item + .find(".g3-action-download") + .toggleClass("is-disabled", !canDownload); + $item.find(".g3-action-preview").toggleClass("is-disabled", !canView); + $item.find(".g3-file-delete-btn").toggleClass("is-disabled", !canDelete); + }, + /** * 获取已上传的文件列表(仅status为uploaded的文件) */ getUploadedFiles: function () { - return this.files; + return this.files.filter(function (file) { + return file.status === "uploaded"; + }); }, isPreviewableFile: function (fileName) { @@ -658,47 +692,34 @@ return !nonPreviewableExts.includes(ext); }, - /** - * 预览上一个文件 - */ + getPreviewFileIndex: function (fileId) { + var uploadedFiles = this.getUploadedFiles(); + return uploadedFiles.findIndex(function (f) { + return f._id === fileId; + }); + }, + previewPrev: function () { var uploadedFiles = this.getUploadedFiles(); if (uploadedFiles.length === 0) return; - if (this.previewIndex > 0) { - this.previewIndex--; - } else { - this.previewIndex = uploadedFiles.length - 1; - } - + var currentIndex = this.getPreviewFileIndex(this.currentPreviewFileId); + var newIndex = + currentIndex > 0 ? currentIndex - 1 : uploadedFiles.length - 1; + this.currentPreviewFileId = uploadedFiles[newIndex]._id; 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; - } - + var currentIndex = this.getPreviewFileIndex(this.currentPreviewFileId); + var newIndex = + currentIndex < uploadedFiles.length - 1 ? currentIndex + 1 : 0; + this.currentPreviewFileId = uploadedFiles[newIndex]._id; this.updatePreview(); }, - base64Encode: function (str) { - return btoa( - encodeURIComponent(str).replace( - /%([0-9A-F]{2})/g, - function (match, p1) { - return String.fromCharCode("0x" + p1); - }, - ), - ); - }, /** * 更新预览区域显示 */ @@ -716,28 +737,35 @@ this.$previewCounter.text("0/0"); this.$btnPrev.prop("disabled", true); this.$btnNext.prop("disabled", true); + this.currentPreviewFileId = null; return; } - if (this.previewIndex >= total) { - this.previewIndex = total - 1; - } - if (this.previewIndex < 0) { - this.previewIndex = 0; + if ( + !this.currentPreviewFileId || + !this.getFileById(this.currentPreviewFileId) + ) { + this.currentPreviewFileId = allFiles[0]._id; } - var currentFile = allFiles[this.previewIndex]; + var currentFile = this.getFileById(this.currentPreviewFileId); + if (!currentFile) { + this.currentPreviewFileId = allFiles[0]._id; + currentFile = allFiles[0]; + } + + var currentIndex = this.getPreviewFileIndex(this.currentPreviewFileId); 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.$btnPrev.prop("disabled", currentIndex === 0); + this.$btnNext.prop("disabled", currentIndex === total - 1); this.$previewName.text(currentFile.name); var counterText = this.language.previewCount - .replace("{current}", this.previewIndex + 1) + .replace("{current}", currentIndex + 1) .replace("{total}", total); this.$previewCounter.text(counterText); @@ -776,18 +804,10 @@ this.$previewContent.html(previewContent); }, - isPreviewContentChanged: function (newContent) { - var currentContent = this.$previewContent.html(); - return currentContent !== newContent; - }, - setPreviewFile: function (fileId) { - var uploadedFiles = this.getUploadedFiles(); - var index = uploadedFiles.findIndex(function (f) { - return f._id === fileId; - }); - if (index >= 0) { - this.previewIndex = index; + var file = this.getFileById(fileId); + if (file) { + this.currentPreviewFileId = fileId; this.updatePreview(); } }, @@ -801,6 +821,9 @@ */ bindEvents: function () { var self = this; + var documentActionMenuEvent = + "click.g3file_actionmenu_" + this.instanceId; + var documentPasteEvent = "paste" + this.documentNamespace; // 选择全部 this.$checkAll.off("change").on("change", function () { @@ -819,6 +842,7 @@ // 批量下载 this.$btnDownload.off("click").on("click", function () { + if (!self.hasPermission(null, "download")) return; var selectedFiles = self.getSelectedFiles(); if (selectedFiles.length === 0) return; @@ -827,6 +851,7 @@ // 批量删除 this.$btnDelete.off("click").on("click", function () { + if (!self.hasPermission(null, "delete")) return; var selectedFiles = self.getSelectedFiles(); if (selectedFiles.length === 0) return; @@ -835,11 +860,13 @@ // 选择文件 this.$btnSelect.off("click").on("click", function () { + if (!self.hasPermission(null, "select")) return; self.$fileInput.click(); }); // 上传 this.$btnUpload.off("click").on("click", function () { + if (!self.hasPermission(null, "upload")) return; var pendingFiles = self.files.filter( (file) => file.status === "pending", ); @@ -864,8 +891,8 @@ // 点击其他地方关闭菜单 $(document) - .off("click.g3file_actionmenu") - .on("click.g3file_actionmenu", function (e) { + .off(documentActionMenuEvent) + .on(documentActionMenuEvent, function (e) { if ( !$(e.target).closest(".g3-action-more").length && !$(e.target).closest(".g3-global-menu").length @@ -909,8 +936,8 @@ // 粘贴文件 $(document) - .off("paste.g3file") - .on("paste.g3file", function (e) { + .off(documentPasteEvent) + .on(documentPasteEvent, function (e) { var clipboardData = e.originalEvent.clipboardData; var files = clipboardData.files; if (files && files.length > 0) { @@ -928,81 +955,53 @@ this.$btnNext.off("click").on("click", function () { self.previewNext(); }); - }, - /** - * 绑定文件项事件 - */ - bindFileItemEvents: function () { - var self = this; - - // 预览 + // 文件列表事件委托(只在 init 时绑定一次) this.$fileList - .off("click", ".g3-action-preview") + // 预览按钮 .on("click", ".g3-action-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); - + var file = self.getFileById(id); if (!file) return; - + if (!self.hasPermission(file, "view")) return; self.setPreviewFile(id); - if (typeof self.options.onPreview === "function") { self.options.onPreview([file]); } - }); - - // 下载 - this.$fileList - .off("click", ".g3-action-download") + }) + // 下载按钮 .on("click", ".g3-action-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); - + var file = self.getFileById(id); if (!file) return; - + if (!self.hasPermission(file, "download")) 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); - + var file = self.getFileById(id); if (!file) return; - + if (!self.hasPermission(file, "delete")) return; self.handleDelete([file]); - }); - - // 文件项点击 - this.$fileList - .off("click", ".g3-file-item") + }) + // 文件项单击预览 .on("click", ".g3-file-item", function (e) { if ( $(e.target).closest( @@ -1011,32 +1010,23 @@ ) { return; } - var id = $(this).data("id"); - var file = self.files.find((f) => f._id === id); - + var file = self.getFileById(id); if (!file) return; - + if (!self.hasPermission(file, "view")) return; if (typeof self.options.onPreview === "function") { self.options.onPreview([file]); } - self.setPreviewFile(id); - }); - - // 更多按钮 - this.$fileList - .off("click", ".g3-action-more") + }) + // 更多按钮 .on("click", ".g3-action-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 @@ -1044,9 +1034,16 @@ self.closeActionMenu(); return; } - self.openActionMenu($el, file); }); + + // 任务关闭按钮事件委托 + this.$container.on("click", ".g3-task-close", function (e) { + e.preventDefault(); + e.stopPropagation(); + var taskId = $(this).closest(".g3-task-center").data("task-id"); + self.closeTask(taskId); + }); }, // ============================================================================ @@ -1156,7 +1153,27 @@ handleDelete: function (files) { var self = this; - if (typeof this.options.onDelete === "function") { + if (this.uiLocked) { + console.warn("UI is locked, please wait..."); + return; + } + + if (typeof this.options.onBeforeDelete === "function") { + var result = this.options.onBeforeDelete(files); + if (result === false) return; + if (typeof result.then === "function") { + result.then(function (confirmed) { + if (confirmed) self.internalDelete(files); + }); + return; + } + if (typeof result === "function") { + result(function (confirmed) { + if (confirmed) self.internalDelete(files); + }); + return; + } + } else if (typeof this.options.onDelete === "function") { this.options.onDelete(files, function (success) { if (success) { self.internalDelete(files); @@ -1372,8 +1389,7 @@ if (data) { Object.assign(file, data); } - this.renderFileList(); - this.updateUI(); + this.updateFileItem(fileId); } }, @@ -1383,8 +1399,6 @@ * @param {Function} callback - 上传完成回调 */ internalUpload: async function (file, callback) { - const self = this; - try { const keyData = await this.getValidAliyunKey(); @@ -1417,25 +1431,10 @@ 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); } }, @@ -1445,117 +1444,111 @@ * @param {Array} files - 文件数组 */ handleUpload: function (files) { - const self = this; - const customUpload = this.options.uploadConfig.customUpload; - let successCount = 0; - let failCount = 0; + var self = this; - // 过滤出未上传的文件 - const pendingFiles = files.filter((file) => file.status === "pending"); + if (this.uiLocked) { + console.warn("UI is locked, please wait..."); + return; + } + + var customUpload = this.options.uploadConfig.customUpload; + var concurrency = 3; + var pendingFiles = files.filter(function (file) { + return 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 = G3FileUtils.generateUniqueId(); - - // 启动上传任务 - this.startTask({ - id: taskId, + var total = pendingFiles.length; + var taskId = this.startTask({ + id: G3FileUtils.generateUniqueId(), title: this.language.batchUpload || "批量上传", total: total, current: 0, currentItem: "", }); - // 逐个上传文件 - pendingFiles.forEach((file) => { - // 更新文件状态为上传中 + var successCount = 0; + var failCount = 0; + var completedCount = 0; + var index = 0; + + function uploadNext() { + if (index >= pendingFiles.length) return Promise.resolve(); + + var file = pendingFiles[index++]; 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, - }); + return new Promise(function (resolve) { + function handleResult(error, uploadedFile) { + completedCount++; if (error) { failCount++; + self.updateFileStatus(file._id, "failed"); + if (typeof self.options.onUploadError === "function") { + self.options.onUploadError(file, error); + } } else { successCount++; + self.updateFileStatus(file._id, "uploaded", uploadedFile); + if (typeof self.options.onUploadSuccess === "function") { + self.options.onUploadSuccess(uploadedFile); + } } - checkComplete(); - }); - } - }); - // 检查是否所有文件都上传完成 - function checkComplete() { - if (successCount + failCount === pendingFiles.length) { - // 完成任务 - self.finishTask({ - id: taskId, - total: total, - success: successCount, - failed: failCount, - }); + self.updateTask({ + id: taskId, + current: completedCount, + currentItem: file.name, + }); - // 触发上传完成回调 - if (typeof self.options.onUploadComplete === "function") { - self.options.onUploadComplete(successCount, failCount); + if (completedCount === total) { + self.finishTask({ + id: taskId, + total: total, + success: successCount, + failed: failCount, + }); + if (typeof self.options.onUploadComplete === "function") { + self.options.onUploadComplete(successCount, failCount); + } + } + + resolve(); } - } + + if (typeof customUpload === "function") { + customUpload(file) + .then(function (uploadedFile) { + handleResult(null, uploadedFile); + }) + .catch(function (error) { + handleResult(error, null); + }); + } else if (self.options.mode === "aliyun") { + self.internalUpload(file, function (error, uploadedFile) { + handleResult(error, uploadedFile); + }); + } else { + handleResult(new Error("No upload method configured"), null); + } + }).then(function () { + return uploadNext(); + }); } + + var runners = []; + for (var i = 0; i < concurrency && i < pendingFiles.length; i++) { + runners.push(uploadNext()); + } + + Promise.all(runners); }, // ============================================================================ @@ -1654,21 +1647,20 @@ * @param {string} options.currentItem - 当前处理项 */ startTask: function (options) { - const { id, title, total = 0, current = 0, currentItem = "" } = options; - - this.currentTask = { - id: id || G3FileUtils.generateUniqueId(), - title: title, - total: total, - current: current, - currentItem: currentItem, + var task = { + id: options.id || G3FileUtils.generateUniqueId(), + title: options.title, + total: options.total || 0, + current: options.current || 0, + currentItem: options.currentItem || "", success: 0, failed: 0, status: "running", }; - + this.tasks.set(task.id, task); this.lockUI(); - this.renderTaskCenter(); + this.renderTaskCenter(task.id); + return task.id; }, /** @@ -1679,20 +1671,12 @@ * @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(); + var task = this.tasks.get(options.id); + if (!task) return; + if (options.current !== undefined) task.current = options.current; + if (options.currentItem !== undefined) + task.currentItem = options.currentItem; + this.updateTaskCenter(options.id); }, /** @@ -1704,21 +1688,18 @@ * @param {number} options.failed - 失败数量 */ finishTask: function (options) { - const { id, total, success = 0, failed = 0 } = options; - - if (!this.currentTask || this.currentTask.id !== id) { - return; + var task = this.tasks.get(options.id); + if (!task) return; + task.status = "finished"; + task.total = options.total !== undefined ? options.total : task.total; + task.success = options.success || 0; + task.failed = options.failed || 0; + task.current = task.total; + this.updateTaskCenter(options.id); + this.tasks.delete(options.id); + if (this.tasks.size === 0) { + this.unlockUI(); } - - 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(); }, /** @@ -1726,23 +1707,33 @@ * @param {string} id - 任务ID */ closeTask: function (id) { - if (this.currentTask && this.currentTask.id === id) { - this.$taskCenter.removeClass("is-visible"); - this.currentTask = null; + var task = this.tasks.get(id); + if (task) { + task.status = "closed"; + this.tasks.delete(id); + } + + var taskCenter = this.$container.find( + `.g3-task-center[data-task-id="${id}"]`, + ); + if (taskCenter.length) { + taskCenter.remove(); + } + this.$taskCenter = null; + + if (this.tasks.size === 0) { + this.unlockUI(); } }, - /** - * 渲染任务中心 UI - */ - renderTaskCenter: function () { - const task = this.currentTask; + renderTaskCenter: function (taskId) { + var task = this.tasks.get(taskId); if (!task) return; let taskCenter = this.$container.find(".g3-task-center"); if (!taskCenter.length) { this.$container.append(` -
+
${task.title} @@ -1767,31 +1758,19 @@
${this.language.task.failed}
-
`); 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(); + this.updateTaskCenter(taskId); }, - /** - * 更新任务中心 UI - */ - updateTaskCenter: function () { - const task = this.currentTask; + updateTaskCenter: function (taskId) { + var task = this.tasks.get(taskId); if (!task || !this.$taskCenter) return; const progress = @@ -1812,7 +1791,6 @@ if (task.status === "finished") { this.$taskCenter.find(".g3-result-section").show(); - this.$taskCenter.find(".g3-complete-button").show(); this.$taskCenter .find(".g3-result-item:first .g3-result-value") .text(task.total); @@ -1824,7 +1802,6 @@ .text(task.failed); } else { this.$taskCenter.find(".g3-result-section").hide(); - this.$taskCenter.find(".g3-complete-button").hide(); } }, @@ -1902,10 +1879,13 @@ * @returns {boolean} 是否有权限 */ hasPermission: function (file, action) { + return this.resolvePermission(file, action); + }, + + resolvePermission: 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) { @@ -1913,14 +1893,12 @@ } } - // 2. 判断全局权限 if (typeof globalPermission === "function") { return !!globalPermission({ file: file, action: action }); } else if (typeof globalPermission === "object") { return !!globalPermission[action]; } - // 默认返回 true return true; }, @@ -1979,11 +1957,9 @@ * @returns {G3File} 实例 */ setFiles: function (files) { - var self = this; - this.files = files.map(function (file) { return { - _id: file._id || self.generateUniqueId(), + _id: file._id || G3FileUtils.generateUniqueId(), name: file.name, size: file.size, uploadDate: file.uploadDate || new Date().toISOString(), @@ -1995,7 +1971,7 @@ }); this.selectedFiles.clear(); - this.previewIndex = 0; + this.currentPreviewFileId = null; this.renderFileList(); this.updateUI(); this.updatePreview(); @@ -2012,10 +1988,11 @@ if (partialState.files) { this.setFiles(partialState.files); } else { - // 其他状态更新时关闭当前任务 - if (this.currentTask) { - this.closeTask(this.currentTask.id); - } + // 其他状态更新时关闭所有运行中的任务 + var self = this; + this.tasks.forEach(function (task, taskId) { + self.closeTask(taskId); + }); } if (partialState.selectedFiles) { @@ -2024,8 +2001,14 @@ } if ( - partialState.globalPermission || - partialState.filePermissionResolver + Object.prototype.hasOwnProperty.call( + partialState, + "globalPermission", + ) || + Object.prototype.hasOwnProperty.call( + partialState, + "filePermissionResolver", + ) ) { this.updatePermissions({ globalPermission: partialState.globalPermission, @@ -2040,10 +2023,13 @@ * 销毁 */ destroy: function () { - this.$container.off(); + // 清理 document 事件 + $(document).off(this.documentNamespace); + $(document).off(".g3file_actionmenu_" + this.instanceId); - // 清理ObjectURL - this.objectUrls.forEach((url) => { + // 清理 ObjectURL + var self = this; + this.objectUrls.forEach(function (url) { try { URL.revokeObjectURL(url); } catch (e) { @@ -2052,10 +2038,24 @@ }); this.objectUrls.clear(); - // 清理引用 + // 清理任务 + this.tasks.forEach(function (task, taskId) { + self.closeTask(taskId); + }); + + // 清理 DOM + this.$container.find(".g3-global-menu").remove(); + this.$container.find(".g3-toast-container").remove(); + this.$container.find(".g3-task-center").remove(); + this.$container.find(".g3-ui-lock").remove(); + this.$container.empty(); + + // 重置状态 this.files = []; this.selectedFiles.clear(); - this.$container.empty(); + this.tasks.clear(); + this.currentPreviewFileId = null; + this.uiLocked = false; console.log("G3File 组件已销毁"); }, @@ -2085,6 +2085,10 @@ } }); + if (typeof options === "string") { + return methodResult !== undefined ? methodResult : this; + } + return instance || this; }; })(jQuery);