diff --git a/code/file-preview/.trae/documents/task_dock_optimization_plan.md b/code/file-preview/.trae/documents/task_dock_optimization_plan.md new file mode 100644 index 00000000..146496ca --- /dev/null +++ b/code/file-preview/.trae/documents/task_dock_optimization_plan.md @@ -0,0 +1,116 @@ +# Task Dock 优化计划 + +## 1. 需求分析 + +### 1.1 现有功能 + +- 当前 `task-dock` 是一个固定在右下角的任务面板 +- 主要用于显示文件下载任务的进度和结果 +- 包含任务标题、进度条、任务名称和结果统计 +- 支持动画效果和基本交互 + +### 1.2 优化目标 + +- 将 `task-dock` 封装为通用的通知组件,类似 Element Plus 的 Notification +- 支持自定义 content,包括 success、warning、error、info 等类型 +- 保留现有的 task 结构和功能 +- 提供更灵活的配置选项 + +## 2. 技术方案 + +### 2.1 组件结构优化 + +1. **重命名和重构**:将 `task-dock` 重命名为更通用的 `notification` 或 `toast` 组件 +2. **类型支持**:添加不同类型的样式和图标支持 +3. **配置选项**:提供更灵活的配置选项,如位置、持续时间、可关闭性等 +4. **内容自定义**:支持自定义内容,包括 HTML 内容 + +### 2.2 核心功能扩展 + +1. **通知类型**:支持 success、warning、error、info 等类型 +2. **任务模式**:保留现有的任务模式,用于显示进度和结果 +3. **自动关闭**:支持设置自动关闭时间 +4. **手动关闭**:支持手动关闭通知 +5. **堆叠显示**:支持多个通知堆叠显示 + +### 2.3 样式优化 + +1. **类型样式**:为不同类型的通知添加不同的样式和图标 +2. **动画效果**:优化动画效果,支持淡入淡出和滑动效果 + +
+ + 3\. 实现步骤 + +### 3.1 修改 HTML 结构 + +1. 重构 `task-dock` 的 HTML 结构,使其更通用 +2. 添加类型相关的 CSS 类和图标 +3. 支持自定义内容的容器 + +### 3.2 扩展 JavaScript 功能 + +1. 创建通用的通知方法,如 `showNotification`、`showTask` 等 +2. 添加类型支持和配置选项 +3. 实现通知的创建、更新和销毁逻辑 +4. 保留现有的任务管理功能 + +### 3.3 优化 CSS 样式 + +1. 为不同类型的通知添加样式 +2. 优化动画效果 +3. 确保样式的可扩展性和可定制性 + +### 3.4 测试和验证 + +1. 测试不同类型的通知显示 +2. 测试任务模式的功能 +3. 测试通知的堆叠和自动关闭功能 +4. 验证与现有功能的兼容性 + +## 4. 预期效果 + +### 4.1 通用通知功能 + +- 支持不同类型的通知显示 +- 支持自定义内容和配置 +- 支持自动关闭和手动关闭 +- 支持多个通知堆叠显示 + +### 4.2 任务管理功能 + +- 保留现有的任务进度显示 +- 支持任务结果统计 +- 与通用通知功能无缝集成 + +### 4.3 视觉效果 + +- 现代化的设计风格 +- 流畅的动画效果 +- 清晰的视觉层次 +- 响应式布局 + +## 5. 风险评估 + +### 5.1 潜在风险 + +- 现有功能可能受到影响 +- 样式冲突可能出现 +- 性能问题(多个通知同时显示) + +### 5.2 风险缓解措施 + +- 保持向后兼容性 +- 仔细测试现有功能 +- 优化通知的创建和销毁逻辑 +- 限制同时显示的通知数量 + +## 6. 时间估计 + +- 设计和规划:1 小时 +- HTML 结构修改:1 小时 +- JavaScript 功能扩展:2 小时 +- CSS 样式优化:1 小时 +- 测试和验证:1 小时 + +总计:6 小时 diff --git a/code/file-preview/.trae/documents/upload_functionality_plan.md b/code/file-preview/.trae/documents/upload_functionality_plan.md new file mode 100644 index 00000000..691b4d52 --- /dev/null +++ b/code/file-preview/.trae/documents/upload_functionality_plan.md @@ -0,0 +1,135 @@ +# 上传功能实现计划 + +## 1. 需求分析 + +### 1.1 现有功能 +- 当前 `G3File` 组件的上传功能通过 `onUpload` 回调函数处理 +- 上传按钮点击时,会获取所有状态为 `pending` 的文件并调用 `onUpload` 回调 +- 没有内置的上传实现,完全依赖外部回调 + +### 1.2 需求目标 +- 实现阿里云内部上传方法 +- 保留外部方法,允许用户动态获取阿里云key等信息 +- 支持外部自定义上传方法 +- 只上传未上传(状态为 `pending`)的文件 +- 上传成功后,将上传成功的 fileitem 传给外部调用,上传成功一个就触发一次 + +## 2. 技术方案 + +### 2.1 核心功能设计 +1. **上传配置**:添加上传相关的配置选项,包括阿里云上传配置和自定义上传方法 +2. **上传方法**:实现阿里云内部上传方法和外部自定义上传方法的支持 +3. **上传流程**:优化上传流程,只上传未上传的文件,上传成功后触发回调 +4. **状态管理**:管理文件的上传状态,包括 `pending`、`uploading`、`uploaded`、`failed` 等 + +### 2.2 配置选项 +- `uploadConfig`:上传配置对象,包含阿里云上传配置和自定义上传方法 +- `onUploadSuccess`:上传成功回调,上传成功一个文件就触发一次 +- `onUploadError`:上传失败回调 +- `onUploadComplete`:所有文件上传完成回调 + +### 2.3 上传流程 +1. 用户点击上传按钮 +2. 组件获取所有状态为 `pending` 的文件 +3. 检查是否配置了自定义上传方法,如果有则使用自定义方法 +4. 如果没有自定义上传方法,则使用阿里云内部上传方法 +5. 逐个上传文件,更新文件状态为 `uploading` +6. 上传成功后,更新文件状态为 `uploaded`,并触发 `onUploadSuccess` 回调 +7. 上传失败后,更新文件状态为 `failed`,并触发 `onUploadError` 回调 +8. 所有文件上传完成后,触发 `onUploadComplete` 回调 + +## 3. 实现步骤 + +### 3.1 修改 HTML 结构 +- 无需修改 HTML 结构,使用现有的上传按钮 + +### 3.2 扩展 JavaScript 功能 +1. **添加上传配置选项**:在 `defaults` 中添加上传相关的配置选项 +2. **实现上传方法**: + - `internalUpload`:内部上传方法,支持阿里云上传 + - `handleUpload`:处理上传逻辑,支持自定义上传方法 +3. **更新上传流程**:修改 `$btnUpload` 的点击事件处理,使用新的上传逻辑 +4. **添加回调方法**:实现 `onUploadSuccess`、`onUploadError`、`onUploadComplete` 回调 + +### 3.3 优化 CSS 样式 +- 为上传状态添加相应的样式,如上传中、上传失败等 + +### 3.4 测试和验证 +1. 测试阿里云内部上传功能 +2. 测试外部自定义上传方法 +3. 测试上传状态管理 +4. 测试回调函数触发 + +## 4. 预期效果 + +### 4.1 上传功能 +- 支持阿里云内部上传 +- 支持外部自定义上传方法 +- 只上传未上传的文件 +- 上传成功后触发回调 + +### 4.2 状态管理 +- 清晰的上传状态管理 +- 上传中、上传成功、上传失败等状态的视觉反馈 + +### 4.3 回调机制 +- 上传成功一个文件就触发一次 `onUploadSuccess` 回调 +- 上传失败触发 `onUploadError` 回调 +- 所有文件上传完成触发 `onUploadComplete` 回调 + +## 5. 风险评估 + +### 5.1 潜在风险 +- 阿里云上传配置复杂,可能出现配置错误 +- 自定义上传方法可能与内部上传逻辑冲突 +- 上传过程中可能出现网络错误 + +### 5.2 风险缓解措施 +- 提供详细的配置文档 +- 确保自定义上传方法与内部上传逻辑的兼容性 +- 添加错误处理和重试机制 + +## 6. 实现细节 + +### 6.1 上传配置 +```javascript +uploadConfig: { + aliyun: { + // 阿里云配置是可选的 + // getUploadToken 是必须的,用于动态获取上传凭证 + getUploadToken: null // 动态获取上传凭证的函数,返回 Promise + }, + customUpload: null // 自定义上传方法 +} +``` + +### 6.2 回调函数 +```javascript +onUploadSuccess: function(file) { + // 上传成功回调 +}, +onUploadError: function(file, error) { + // 上传失败回调 +}, +onUploadComplete: function(successCount, failCount) { + // 所有文件上传完成回调 +} +``` + +### 6.3 上传方法 +- `internalUpload(file, callback)`:内部上传方法 +- `handleUpload(files)`:处理上传逻辑 + +## 7. 代码结构 + +### 7.1 主要修改文件 +- `custom-file/index.js`:扩展上传功能 + +### 7.2 新增方法 +- `internalUpload`:内部上传方法 +- `handleUpload`:处理上传逻辑 +- `updateFileStatus`:更新文件状态 + +### 7.3 修改方法 +- `init`:初始化上传配置 +- `bindEvents`:更新上传按钮的点击事件处理 diff --git a/code/file-preview/custom-file/feedback-dock-demo.html b/code/file-preview/custom-file/feedback-dock-demo.html new file mode 100644 index 00000000..cfa9a7e1 --- /dev/null +++ b/code/file-preview/custom-file/feedback-dock-demo.html @@ -0,0 +1,624 @@ + + + + + + Global Feedback Dock Demo + + + +
+

Global Feedback Dock Demo

+ +
+

1. 任务模式(Task Mode)

+

用于展示批量任务进度,如下载、上传等

+
+ + +
+
+ +
+

2. 提示模式(Toast Mode)

+

用于单次操作反馈,如操作成功、失败等

+
+ + + + +
+
+ +
+

3. 单个文件下载失败示例

+

模拟单个文件下载失败的场景

+
+ +
+
+
+ + +
+
+ 任务进行中 +
+ +
+
+
+ +
+
+ 0 / 0 + 0% +
+
+
+
+
+
+ 总计: 0 | 成功: 0 | 失败: 0 +
+
+ + +
+
+ + +
+ + + + \ No newline at end of file diff --git a/code/file-preview/custom-file/index.css b/code/file-preview/custom-file/index.css index bb0b6c7d..694ea824 100644 --- a/code/file-preview/custom-file/index.css +++ b/code/file-preview/custom-file/index.css @@ -1,21 +1,27 @@ +/* ============================================================================ + * G3File 样式文件 + * ============================================================================ */ + /* 整体布局 */ .g3-file-container { height: 100%; display: flex; flex-direction: column; - background: #ffffff; - overflow: hidden; } +/* 拖拽时的样式 */ +.g3-file-container.drag-over { + background: rgba(22, 93, 255, 0.05); + border: 2px dashed #165dff; +} + /* 头部样式 */ .g3-file-container .header { display: flex; align-items: center; - padding: 10px 14px; - background: #fafbfc; border-bottom: 1px solid #eef0f2; } @@ -25,18 +31,12 @@ flex: 1; min-height: 0; padding: 4px 0px; - overflow: auto; - display: flex; flex-wrap: wrap; - align-content: flex-start; - border-left: 1px solid #eef0f2; border-right: 1px solid #eef0f2; - - /* justify-content: center; */ } .g3-file-container .main > * { @@ -60,9 +60,7 @@ display: flex; align-items: center; justify-content: space-between; - padding: 10px 14px; - background: #fafbfc; border-top: 1px solid #eee; } @@ -115,11 +113,9 @@ transition: all 0.2s ease; border: none; width: auto; - height: 28px; line-height: 1; padding: 0 15px; - display: flex; align-items: center; justify-content: center; @@ -155,11 +151,9 @@ width: calc(50% - 26px); position: relative; cursor: pointer; - border: 1px solid #eef0f2; border-radius: 8px; background: #fff; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); } @@ -228,7 +222,6 @@ font-size: 12px; color: #999; line-height: 1.4; - display: flex; flex-direction: column; align-items: center; @@ -258,7 +251,6 @@ display: flex; align-items: center; justify-content: flex-end; - overflow: hidden; border-top: 1px solid #f0f2f5; } @@ -270,7 +262,6 @@ align-items: center; justify-content: center; gap: 4px; - cursor: pointer; font-size: 12px; border-right: 1px solid #f0f2f5; @@ -368,6 +359,45 @@ z-index: 1; } +/* 上传中标记 */ +.g3-file-item .g3-file-uploading-badge { + position: absolute; + top: 6px; + right: 6px; + background: #165dff; + color: white; + font-size: 10px; + padding: 2px 6px; + border-radius: 10px; + z-index: 1; +} + +/* 上传失败标记 */ +.g3-file-item .g3-file-failed-badge { + position: absolute; + top: 6px; + right: 6px; + background: #ff4d4f; + color: white; + font-size: 10px; + padding: 2px 6px; + border-radius: 10px; + z-index: 1; +} + +/* 上传成功标记 */ +.g3-file-item .g3-file-uploaded-badge { + position: absolute; + top: 6px; + right: 6px; + background: #52c41a; + color: white; + font-size: 10px; + padding: 2px 6px; + border-radius: 10px; + z-index: 1; +} + /* 浮动删除按钮 */ .g3-file-container .g3-file-delete-btn { position: absolute; @@ -385,7 +415,6 @@ z-index: 100; font-size: 12px; border: 1px solid #e8e8e8; - transition: all 0.2s ease; } @@ -404,14 +433,12 @@ .g3-global-menu { position: absolute; z-index: 9999; - width: 160px; background: #fff; border: 1px solid #eee; border-radius: 8px; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08); - position: fixed; /* ⭐必须,不然一定会错位 */ - + position: fixed; visibility: hidden; pointer-events: none; } @@ -474,7 +501,7 @@ border-radius: 8px 8px 0 0; } -.task-dock .dock-header .title { +.task-dock .dock-header .dock-title { font-size: 14px; font-weight: 500; color: #333; @@ -528,12 +555,6 @@ text-overflow: ellipsis; } -.task-dock .task-type { - font-size: 12px; - color: #999; - margin-bottom: 12px; -} - .task-dock .bar { width: 100%; height: 6px; @@ -556,6 +577,110 @@ text-align: right; } +/* 通知容器 */ +.notification-container { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + z-index: 9999; +} + +/* 通知样式 */ +.notification { + min-width: 300px; + max-width: 400px; + background: #ffffff; + border: 1px solid #eef0f2; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + padding: 16px; + display: flex; + align-items: flex-start; + justify-content: space-between; + pointer-events: auto; + transition: all 0.3s ease; +} + +/* 通知内容 */ +.notification-content { + flex: 1; +} + +/* 通知标题 */ +.notification-title { + font-size: 14px; + font-weight: 500; + color: #333; + margin-bottom: 8px; +} + +/* 通知消息 */ +.notification-message { + font-size: 14px; + color: #666; + line-height: 1.4; +} + +/* 关闭按钮 */ +.notification-close { + width: 24px; + height: 24px; + border: none; + background: transparent; + color: #666; + font-size: 16px; + cursor: pointer; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + margin-left: 12px; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.notification-close:hover { + background: #f0f2f5; + color: #333; +} + +/* 通知类型样式 */ +.notification-success { + border-left: 4px solid #52c41a; +} + +.notification-warning { + border-left: 4px solid #faad14; +} + +.notification-error { + border-left: 4px solid #ff4d4f; +} + +.notification-info { + border-left: 4px solid #165dff; +} + +/* 通知动画 */ +@keyframes slideIn { + from { + transform: translateX(100%) translateY(100%); + opacity: 0; + } + to { + transform: translateX(0) translateY(0); + opacity: 1; + } +} + +.notification-fade-out { + opacity: 0; + transform: translateY(-10px); +} + /* UI Lock 样式 */ .ui-lock { position: fixed; @@ -566,4 +691,4 @@ background: rgba(0, 0, 0, 0.05); z-index: 9998; display: none; -} +} \ No newline at end of file diff --git a/code/file-preview/custom-file/index.js b/code/file-preview/custom-file/index.js index 885883d2..40ad48d6 100644 --- a/code/file-preview/custom-file/index.js +++ b/code/file-preview/custom-file/index.js @@ -1,10 +1,9 @@ (function ($) { "use strict"; - - // ===================== + // ============================================================================ // G3File 构造函数 - // ===================== + // ============================================================================ function G3File(container, options) { // 实例隔离 this.$container = $(container); @@ -13,13 +12,15 @@ this.files = []; this.selectedFiles = new Set(); this.objectUrls = new Map(); - this.language = this.languages[this.options.language] || this.languages['zh-CN']; + this.language = + this.languages[this.options.language] || this.languages["zh-CN"]; + // 操作菜单状态 this.actionMenuState = { open: false, fileId: null, anchorEl: null, - items: [] + items: [], }; // 任务管理 @@ -27,104 +28,136 @@ this.taskStats = { total: 0, success: 0, - fail: 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', + 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 + view: true, }, // 单文件权限解析函数(仅支持函数) - filePermissionResolver: null + filePermissionResolver: null, }, - // ===================== - // 多语言 - // ===================== + /** + * 多语言配置 + */ languages: { - 'zh-CN': { - selectAll: '全选', - download: '下载', + "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: '关闭' + 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", }, - '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' - } }, - // ===================== - // 静态icon - // ===================== + /** + * 静态图标映射 + */ staticIconMap: { download: ` @@ -142,9 +175,18 @@ `, more: ` - ` + `, }, + // ============================================================================ + // 初始化和核心方法 + // ============================================================================ + + /** + * 加载脚本 + * @param {Array} items - 脚本配置数组 + * @param {Function} callback - 加载完成回调 + */ loadScripts: function (items, callback) { var remaining = items.length; @@ -194,10 +236,10 @@ }); }, - - // ===================== - // 初始化 - // ===================== + /** + * 初始化 + * @returns {G3File} 实例 + */ init: function () { var self = this; @@ -232,36 +274,232 @@ return this; }, - // ===================== - // 生成唯一ID - // ===================== + // ============================================================================ + // 工具方法 + // ============================================================================ + + /** + * 生成唯一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 ext = filename.split(".").pop().toLowerCase(); var iconMap = { pdf: '', png: '', jpg: '', - default: '' + default: + '', }; - // 不做处理 if (this.isImageFile(filename)) { - return '' + 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 = `
@@ -305,11 +543,11 @@
+
- Task Running + Task Running
-
@@ -319,7 +557,6 @@ 0%
-
@@ -333,45 +570,55 @@ 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.$uiLock = this.$container.find('.ui-lock'); + 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'); + var actionMenu = this.$container.find(".g3-global-menu"); if (!actionMenu.length) { this.$container.append('
'); - actionMenu = this.$container.find('.g3-global-menu'); + actionMenu = this.$container.find(".g3-global-menu"); } var actionMenuState = this.actionMenuState; if (!actionMenuState.open) { - actionMenu.removeClass('is-open'); + actionMenu.removeClass("is-open"); return; } - var html = ''; + var html = ""; actionMenuState.items.forEach(function (item) { var isDisabled = false; - if (typeof item.disabled === 'function') { + 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' : ''; + var disabledClass = isDisabled ? "is-disabled" : ""; html += `
${item.title}
`; }); @@ -379,839 +626,89 @@ var self = this; - actionMenu.find('.g3-action-menu-item').off('click').on('click', function (e) { - e.stopPropagation(); + actionMenu + .find(".g3-action-menu-item") + .off("click") + .on("click", function (e) { + e.stopPropagation(); - var idx = $(this).index(); - var item = actionMenuState.items[idx]; + 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; - } + 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; + if (isDisabled) return; - item.onClick && item.onClick(self.getFileById(actionMenuState.fileId)); + item.onClick && + item.onClick(self.getFileById(actionMenuState.fileId)); - self.closeActionMenu(); - }); + self.closeActionMenu(); + }); - // ========================= - // ⭐ 强制定位到 more 按钮 - // ========================= - var $anchor = $(actionMenuState.anchorEl).closest('.g3-file-action.more'); + 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 viewportW = window.innerWidth; - var viewportH = window.innerHeight; - - // ========================= - // ⭐ fixed 模式:不要 scrollY! - // ========================= var top = rect.bottom; var left = rect.right - menuWidth; - var spaceBottom = viewportH - rect.bottom; - var spaceTop = rect.top; + var spaceBottom = mainRect.bottom - rect.bottom; // main 容器底部到锚点的距离 + var spaceTop = rect.top - mainRect.top; // 锚点到 main 容器顶部的距离 var openUp = spaceBottom < menuHeight && spaceTop > menuHeight; if (openUp) { - // 向上打开时,菜单顶部应该与 file-item 顶部对齐,而不是 file-action 顶部 - var $fileItem = $anchor.closest('.g3-file-item'); - var fileItemRect = $fileItem[0].getBoundingClientRect(); - top = fileItemRect.top; + top = rect.top - menuHeight; // 在锚点上方显示 } - // 左侧兜底 - if (rect.right < menuWidth) { - left = rect.left; + // 确保菜单不超出 main 容器左右边界 + if (left < mainRect.left) { + left = mainRect.left; + } + if (left + menuWidth > mainRect.right) { + left = mainRect.right - menuWidth; } - left = Math.max(8, Math.min(left, viewportW - menuWidth - 8)); + // 确保菜单不超出 main 容器上下边界 + if (top < mainRect.top) { + top = mainRect.top; + } + if (top + menuHeight > mainRect.bottom) { + top = mainRect.bottom - menuHeight; + } actionMenu.css({ top: top, - left: left + left: left, }); requestAnimationFrame(function () { - actionMenu.addClass('is-open'); - }); - }, - getFileById: function (id) { - return this.files.find(function (f) { - return f.id === id; + actionMenu.addClass("is-open"); }); }, - 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; - - // ===== 🚨 关键1:小图/中图直接不压 ===== - 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); - }); - - // ===== 🚨 关键2:只做“单次轻压” ===== - // 不做循环,不做二分 - let quality = 0.92; - - // 只有超大图才稍微降一点 - if (originSize > 4 * 1024 * 1024) { - quality = 0.88; - } - - const blob = await getBlob(quality); - - // ===== 🚨 关键3:防止压太小(核心修复)===== - if (blob && blob.size < minSize) { - // ❗直接回退高质量版本 - const safe = await getBlob(0.97); - return safe || file; - } - - return blob || file; - }, - // ===================== - // 绑定事件 - // ===================== - 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); - } - }); - - // 文件选择 - 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(); - } - }); - - }, - - // ===================== - // 处理文件选择 - // ===================== - 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); - }, - - - - // ===================== - // 任务管理 - // ===================== - 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(); - this.lockUI(); - - return newTask.id; - }, - - updateTask: function (taskId, updates) { - const task = this.tasks.find(t => t.id === taskId); - if (task) { - Object.assign(task, updates); - this.updateTaskDock(); - } - }, - - 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(); - }, - - // ===================== - // Task Dock UI 管理 - // ===================== - showTaskDock: function () { - if (this.$taskDock) { - this.$taskDock.show(); - this.bindTaskDockEvents(); - } - }, - - hideTaskDock: function () { - if (this.$taskDock) { - this.$taskDock.hide(); - } - }, - - minimizeTaskDock: function () { - if (this.$taskDock) { - const dockBody = this.$taskDock.find('.dock-body'); - if (dockBody.is(':visible')) { - dockBody.hide(); - } else { - dockBody.show(); - } - } - }, - - 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; - - // 更新 UI - 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('.task-type').text(currentTask.type); - this.$taskDock.find('.bar .bar-inner').css('width', `${currentTask.progress}%`); - this.$taskDock.find('.task-result').text(`Total: ${this.taskStats.total} | Success: ${this.taskStats.success} | Fail: ${this.taskStats.fail}`); - } - }, - - bindTaskDockEvents: function () { - const self = this; - - // 绑定关闭按钮事件 - this.$taskDock.find('.dock-actions button[title="close"]').off('click').on('click', function () { - self.clearTasks(); - }); - - // 绑定最小化按钮事件 - this.$taskDock.find('.dock-actions button[title="minimize"]').off('click').on('click', function () { - self.minimizeTaskDock(); - }); - }, - - // ===================== - // UI Lock 功能 - // ===================== - lockUI: function () { - if (this.$uiLock) { - this.$uiLock.show(); - } - }, - - unlockUI: function () { - if (this.$uiLock) { - this.$uiLock.hide(); - } - }, - - isUILocked: function () { - return this.$uiLock && this.$uiLock.is(':visible'); - }, - - // ===================== - // 绑定文件项事件 - // ===================== - 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); - }); - }, - - - - // ===================== - // 处理下载 - // ===================== - 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]); - } - }, - - // ===================== - // 处理删除 - // ===================== - 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); - }, - - // ===================== - // 内部下载 - // ===================== - internalDownload: async function (file) { - if (!file) { - console.warn('下载失败:文件对象无效', file); - 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); - return; - } - - saveAs(blob, file.name || '未命名文件'); - - } catch (err) { - console.error('文件下载失败:', err); - } - }, - - // ===================== - // 内部批量下载 - // ===================== - 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}`); - - // 所有任务完成后,保持 dock 显示一段时间,让用户查看结果 - setTimeout(() => { - this.clearTasks(); - }, 3000); - - } catch (err) { - console.error("批量下载失败:", err); - // 失败时清理任务 - this.clearTasks(); - } - }, - - // ===================== - // 内部删除 - // ===================== - 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(); - }, - - // ===================== - // 获取文件Blob - // ===================== - 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; - }, - - // ===================== - // 获取唯一文件名 - // ===================== - 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; - }, - - // ===================== - // 判断是否为图片文件 - // ===================== - 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); - }, - - // ===================== - // 格式化文件大小 - // ===================== - 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]; - }, - - // ===================== - // 格式化日期 - // ===================== - 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 {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'); + const previewDisabled = !this.hasPermission(file, "view"); + const downloadDisabled = !this.hasPermission(file, "download"); + const deleteDisabled = !this.hasPermission(file, "delete"); let content = ""; @@ -1228,24 +725,43 @@ content = this.getFileIcon(file.name); } - const pendingBadge = file.status === 'pending' ? ` -
- ${this.language.pending} -
- ` : ''; + 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}
- ${pendingBadge} + ${statusBadge} ${deleteButton}
@@ -1255,15 +771,15 @@
-
+
${this.staticIconMap.preview}
-
+
${this.staticIconMap.download}
-
+
${this.staticIconMap.more}
@@ -1271,30 +787,9 @@ `; }, - closeActionMenu: function () { - this.actionMenuState.open = false; - this.actionMenuState.fileId = null; - this.actionMenuState.anchorEl = null; - this.actionMenuState.items = []; - - this.renderActionMenu(); - }, - 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(); - }, - - // ===================== - // 渲染文件列表 - // ===================== + /** + * 渲染文件列表 + */ renderFileList: function () { if (this.files.length === 0) { this.$fileList.hide(); @@ -1305,7 +800,7 @@ this.$fileList.show(); this.$emptyTip.hide(); - var html = ''; + var html = ""; var self = this; this.files.forEach(function (file) { html += self.createFileItem(file); @@ -1315,130 +810,1248 @@ this.bindFileItemEvents(); }, - // ===================== - // 更新UI - // ===================== + /** + * 更新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')); + 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 canBatchDownload = + this.hasPermission(null, "download") && hasSelectedFiles; + this.$btnDownload?.prop("disabled", !canBatchDownload); // 批量删除按钮:需要全局权限且有选中文件 - var canBatchDelete = this.hasPermission(null, 'delete') && hasSelectedFiles; - this.$btnDelete?.prop('disabled', !canBatchDelete); + 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 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; + var pendingCount = this.files.filter( + (file) => file.status === "pending", + ).length; this.$pendingCount?.text(pendingCount); // 更新文件项状态 - this.$fileList.find('.g3-file-item').each(function () { + 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); + 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); + $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'); + 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); + $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); }); }, - // ===================== - // 获取选中文件 - // ===================== - getSelectedFiles: function () { - return this.files.filter(file => this.selectedFiles.has(file.id)); - }, + // ============================================================================ + // 事件绑定相关方法 + // ============================================================================ - // ===================== - // 设置文件 - // ===================== - setFiles: function (files) { + /** + * 绑定事件 + */ + bindEvents: function () { 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.$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.selectedFiles.clear(); - this.renderFileList(); - this.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; - return this; + 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); + } + }); }, - // ===================== - // 更新状态 - // ===================== - updateState: function (partialState) { - if (partialState.files) { - this.setFiles(partialState.files); - } else { - // 其他状态更新时清理任务 - this.clearTasks(); + /** + * 绑定文件项事件 + */ + 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 (partialState.selectedFiles) { - this.selectedFiles = new Set(partialState.selectedFiles); + 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(); } + }, - if (partialState.globalPermission || partialState.filePermissionResolver) { - this.updatePermissions({ - globalPermission: partialState.globalPermission, - filePermissionResolver: partialState.filePermissionResolver + /** + * 内部上传方法,支持阿里云上传 + * @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); }); } - return this; + // 自动关闭 + 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') { + if (typeof filePermissionResolver === "function") { var fileResult = filePermissionResolver({ file: file, action: action }); if (fileResult !== undefined) { return !!fileResult; @@ -1446,9 +2059,9 @@ } // 2. 判断全局权限 - if (typeof globalPermission === 'function') { + if (typeof globalPermission === "function") { return !!globalPermission({ file: file, action: action }); - } else if (typeof globalPermission === 'object') { + } else if (typeof globalPermission === "object") { return !!globalPermission[action]; } @@ -1456,9 +2069,10 @@ return true; }, - // ===================== - // 更新权限配置 - // ===================== + /** + * 更新权限配置 + * @param {Object} config - 权限配置 + */ updatePermissions: function (config) { if (!config) return; @@ -1473,9 +2087,90 @@ 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(); @@ -1484,7 +2179,7 @@ try { URL.revokeObjectURL(url); } catch (e) { - console.warn('释放ObjectURL失败:', e); + console.warn("释放ObjectURL失败:", e); } }); this.objectUrls.clear(); @@ -1494,13 +2189,13 @@ this.selectedFiles.clear(); this.$container.empty(); - console.log('G3File 组件已销毁'); - } + console.log("G3File 组件已销毁"); + }, }; - // ========================== + // ============================================================================ // jQuery 插件封装 - // ========================== + // ============================================================================ $.fn.G3File = function (options) { var args = Array.prototype.slice.call(arguments, 1); var instance; @@ -1512,8 +2207,8 @@ if (!instance) { instance = new G3File(this, options); - $this.data('G3File', instance); - } else if (typeof options === 'string') { + $this.data("G3File", instance); + } else if (typeof options === "string") { var method = options; if (instance[method]) { // 保存方法调用结果 diff --git a/code/file-preview/index.html b/code/file-preview/index.html index cd5416a9..217ab83d 100644 --- a/code/file-preview/index.html +++ b/code/file-preview/index.html @@ -64,137 +64,230 @@