Files
workspace/code/file-preview/custom-file/index.js
T
2026-04-27 17:26:15 +08:00

1514 lines
47 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function ($) {
'use strict';
// =====================
// G3File 构造函数
// =====================
function G3File(container, options) {
// 实例隔离
this.$container = $(container);
this.options = $.extend({}, this.defaults, options);
this.instanceId = this.generateUniqueId();
this.files = [];
this.selectedFiles = new Set();
this.objectUrls = new Map();
this.language = this.languages[this.options.language] || this.languages['zh-CN'];
this.actionMenuState = {
open: false,
fileId: null,
anchorEl: null,
items: []
};
// 任务管理
this.tasks = [];
this.taskStats = {
total: 0,
success: 0,
fail: 0
};
this.init();
}
// =====================
// 原型方法
// =====================
G3File.prototype = {
// =====================
// 默认配置
// =====================
defaults: {
data: [],
onDelete: null,
onDownload: null,
onSelect: null,
onUpload: null,
onPreview: null,
getActionMenu: null,
language: 'zh-CN',
maxFileSize: 10 * 1024 * 1024,
// 全局权限(支持对象或函数)
globalPermission: {
select: true,
upload: true,
download: true,
delete: true,
view: true
},
// 单文件权限解析函数(仅支持函数)
filePermissionResolver: null
},
// =====================
// 多语言
// =====================
languages: {
'zh-CN': {
selectAll: '全选',
download: '下载',
downloadBatch: "批量下载",
delete: '删除',
upload: '上传',
select: '选择',
more: '更多',
confirmDelete: '确定要删除选中的 {count} 个文件吗?',
downloading: '准备下载 {count} 个文件',
deleting: '删除中...',
name: '名称',
size: '大小',
date: '日期',
pending: '未上传',
selectFiles: '选择文件',
emptyTip1: '拖拽文件到这里 …',
emptyTip2: '支持多文件同时上传/下载',
emptyTip3: '支持粘贴图片上传',
fileSizeError: '文件大小超过限制',
preview: '预览',
downloadingStatus: '下载中:',
downloadingFile: '正在下载:',
downloadComplete: '下载完成',
downloadResult: '共{total}文件,{success}成功,{failed}失败',
close: '关闭'
},
'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: `
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M12 15V3m9 12v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10l5 5l5-5"/></g></svg>
`,
delete: `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><!-- Icon from Lucide by Lucide Contributors - https://github.com/lucide-icons/lucide/blob/main/LICENSE --><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 6L6 18M6 6l12 12"/></svg>
`,
select: `
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12.586 12.586L19 19M3.688 3.037a.497.497 0 0 0-.651.651l6.5 15.999a.501.501 0 0 0 .947-.062l1.569-6.083a2 2 0 0 1 1.448-1.479l6.124-1.579a.5.5 0 0 0 .063-.947z"/></svg>
`,
upload: `
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v12m5-7l-5-5l-5 5m14 7v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/></svg>
`,
preview: `
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Lucide by Lucide Contributors - https://github.com/lucide-icons/lucide/blob/main/LICENSE --><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m21 21l-4.34-4.34"/><circle cx="11" cy="11" r="8"/></g></svg> `,
more: `
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M17 12h.01M12 12h.01M7 12h.01"/></g></svg>
`
},
loadScripts: function (items, callback) {
var remaining = items.length;
function done() {
remaining--;
if (remaining === 0) {
callback && callback();
}
}
items.forEach(function (item) {
// 已加载
if (item.check && item.check()) {
done();
return;
}
// 防重复加载
var key = item.key;
if (window[key + "_loading"]) {
var timer = setInterval(function () {
if (item.check && item.check()) {
clearInterval(timer);
done();
}
}, 50);
return;
}
window[key + "_loading"] = true;
var script = document.createElement("script");
script.src = item.src;
script.onload = function () {
window[key + "_loading"] = false;
done();
};
script.onerror = function () {
window[key + "_loading"] = false;
console.error(key + " load failed");
done();
};
document.head.appendChild(script);
});
},
// =====================
// 初始化
// =====================
init: function () {
var self = this;
this.loadScripts(
[
{
key: "fileSaver",
src: "/custom-file/libs/FileSaver.min.js",
check: function () {
return typeof window.saveAs === "function";
},
},
{
key: "jsZip",
src: "/custom-file/libs/jszip.min.js",
check: function () {
return typeof window.JSZip === "function";
},
},
],
function () {
self.render();
self.bindEvents();
self.updateUI();
if (self.options.data && self.options.data.length) {
self.setFiles(self.options.data);
}
},
);
return this;
},
// =====================
// 生成唯一ID
// =====================
generateUniqueId: function () {
return 'file_' + Math.random().toString(36).slice(2) + Date.now();
},
// =====================
// 获取文件图标
// =====================
getFileIcon: function (filename) {
var ext = filename.split('.').pop().toLowerCase();
var iconMap = {
pdf: '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path fill="#909090" d="m24.1 2.072l5.564 5.8v22.056H8.879V30h20.856V7.945z"/><path fill="#f4f4f4" d="M24.031 2H8.808v27.928h20.856V7.873z"/><path fill="#7a7b7c" d="M8.655 3.5h-6.39v6.827h20.1V3.5z"/><path fill="#dd2025" d="M22.472 10.211H2.395V3.379h20.077z"/></svg>',
png: '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="#5D87FF" d="M14 14.252a1.998 1.998 0 0 1-1.628-.872l-.372-.56a2 2 0 0 0-1.629-.872H5a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6a2 2 0 0 0-2-2h-2.37a1.998 1.998 0 0 1 0 .504z"/><path fill="#FF5D5D" d="M19 10V4a2 2 0 0 0-2-2h-3a2 2 0 0 0-2 2v.847a2 2 0 0 1-1.165 1.772l-.416.211a2 2 0 0 0-1.166 1.772V10h10z"/></svg>',
jpg: '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="#FF9500" d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/><path fill="#FFB74D" d="M12 18c-3.313 0-6-2.687-6-6s2.687-6 6-6 6 2.687 6 6-2.687 6-6 6z"/><path fill="#FFD54F" d="M12 14c-1.105 0-2-.895-2-2s.895-2 2-2 2 .895 2 2-.895 2-2 2z"/></svg>',
default: '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 16 16"><g fill="none"><path fill="url(#SVG1)" d="M8.004 1L9.5 4.5L13 6l-.008 7.5a1.5 1.5 0 0 1-1.5 1.5H4.5A1.5 1.5 0 0 1 3 13.5v-11A1.5 1.5 0 0 1 4.5 1z"/><defs><linearGradient id="SVG1" x1="9.994" x2="11.264" y1="1" y2="12.781" gradientUnits="userSpaceOnUse"><stop stop-color="#6CE0FF"/><stop offset="1" stop-color="#4894FE"/></linearGradient></defs></g></svg>'
};
// 不做处理
if (this.isImageFile(filename)) {
return ''
}
return iconMap[ext] || iconMap.default;
},
// =====================
// 渲染
// =====================
render: function () {
const html = `
<div class="g3-file-container">
<div class="header">
<div class="check-all">
<input type="checkbox" class="check-all-checkbox" style="cursor: pointer;" />
<span>${this.language.selectAll}</span>
</div>
<button class="download-all" disabled>
${this.staticIconMap.download}
${this.language.downloadBatch}
</button>
<button class="delete-all" disabled>
${this.staticIconMap.delete}
${this.language.delete}
</button>
</div>
<div class="main" style="display: none;">main</div>
<div class="empty-tip">
<span>${this.language.emptyTip1}</span>
<span>${this.language.emptyTip2}</span>
<span>${this.language.emptyTip3}</span>
</div>
<div class="footer">
<div class="pending-count">
<span>${this.language.pending}</span>
<span class="pending-number">0</span>
</div>
<div>
<button class="select" disabled>
${this.staticIconMap.select}
${this.language.select}
</button>
<button class="upload" disabled>
${this.staticIconMap.upload}
${this.language.upload}
</button>
</div>
<input type="file" class="file-input" style="display: none;" multiple />
</div>
<div class="g3-global-menu"></div>
</div>
<div class="task-dock">
<div class="dock-header">
<span class="title">Task Running</span>
<div class="dock-actions">
<button title="minimize">−</button>
<button title="close">×</button>
</div>
</div>
<div class="dock-body">
<div class="progress-line">
<span class="count">0 / 0</span>
<span class="percent">0%</span>
</div>
<div class="task-name"></div>
<div class="task-type"></div>
<div class="bar">
<div class="bar-inner"></div>
</div>
<div class="task-result">
Total: 0 | Success: 0 | Fail: 0
</div>
</div>
</div>
<div class="ui-lock"></div>
`;
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');
return this;
},
renderActionMenu: function () {
var actionMenu = this.$container.find('.g3-global-menu');
if (!actionMenu.length) {
this.$container.append('<div class="g3-global-menu"></div>');
actionMenu = this.$container.find('.g3-global-menu');
}
var actionMenuState = this.actionMenuState;
if (!actionMenuState.open) {
actionMenu.removeClass('is-open');
return;
}
var html = '';
actionMenuState.items.forEach(function (item) {
var isDisabled = false;
if (typeof item.disabled === 'function') {
isDisabled = item.disabled(self.getFileById(actionMenuState.fileId));
} else if (item.disabled !== undefined) {
isDisabled = item.disabled;
}
var disabledClass = isDisabled ? 'is-disabled' : '';
html += `<div class="g3-action-menu-item ${disabledClass}">${item.title}</div>`;
});
actionMenu.html(html);
var self = this;
actionMenu.find('.g3-action-menu-item').off('click').on('click', function (e) {
e.stopPropagation();
var idx = $(this).index();
var item = actionMenuState.items[idx];
// 检查是否禁用
var isDisabled = false;
if (typeof item.disabled === 'function') {
isDisabled = item.disabled(self.getFileById(actionMenuState.fileId));
} else if (item.disabled !== undefined) {
isDisabled = item.disabled;
}
if (isDisabled) return;
item.onClick && item.onClick(self.getFileById(actionMenuState.fileId));
self.closeActionMenu();
});
// =========================
// ⭐ 强制定位到 more 按钮
// =========================
var $anchor = $(actionMenuState.anchorEl).closest('.g3-file-action.more');
var rect = $anchor[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 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;
}
// 左侧兜底
if (rect.right < menuWidth) {
left = rect.left;
}
left = Math.max(8, Math.min(left, viewportW - menuWidth - 8));
actionMenu.css({
top: top,
left: left
});
requestAnimationFrame(function () {
actionMenu.addClass('is-open');
});
},
getFileById: function (id) {
return this.files.find(function (f) {
return f.id === id;
});
},
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;
},
// =====================
// 创建文件项
// =====================
createFileItem: function (file) {
const previewDisabled = !this.hasPermission(file, 'view');
const downloadDisabled = !this.hasPermission(file, 'download');
const deleteDisabled = !this.hasPermission(file, 'delete');
let content = '';
if (this.isImageFile(file.name)) {
content = `
<div class="g3-img-container">
<img src="${file.url}" alt="${file.name}" class="g3-file-img" onError="this.parentElement.classList.add('is-error'); this.style.display='none';" />
<div class="g3-img-error">
${this.staticIconMap.error || '<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#999" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>'}
</div>
</div>
`;
} else {
content = this.getFileIcon(file.name);
}
const pendingBadge = file.status === 'pending' ? `
<div class="g3-file-pending-badge">
${this.language.pending}
</div>
` : '';
const deleteButton = `
<div class="g3-file-delete-btn ${deleteDisabled ? 'is-disabled' : ''}">
${this.staticIconMap.delete}
</div>
`;
return `
<div class="g3-file-item" data-id="${file.id}">
<input type="checkbox" class="g3-file-checkbox" />
<label class="g3-checkbox-label"></label>
<div class="g3-file-preview">${content}</div>
${pendingBadge}
${deleteButton}
<div class="g3-file-info">
<span class="g3-file-name">${file.name}</span>
<span>${this.formatFileSize(file.size)}</span>
<span>${this.formatDate(file.uploadDate)}</span>
</div>
<div class="g3-file-actions">
<div class="g3-file-action preview ${previewDisabled ? 'is-disabled' : ''}">
${this.staticIconMap.preview}
</div>
<div class="g3-file-action download ${downloadDisabled ? 'is-disabled' : ''}">
${this.staticIconMap.download}
</div>
<div class="g3-file-action more">
${this.staticIconMap.more}
</div>
</div>
</div>
`;
},
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();
this.$emptyTip.show();
return;
}
this.$fileList.show();
this.$emptyTip.hide();
var html = '';
var self = this;
this.files.forEach(function (file) {
html += self.createFileItem(file);
});
this.$fileList.html(html);
this.bindFileItemEvents();
},
// =====================
// 更新UI
// =====================
updateUI: function () {
var self = this;
var hasSelectedFiles = this.selectedFiles.size > 0;
// 更新全局按钮状态
this.$btnSelect?.prop('disabled', !this.hasPermission(null, 'select'));
this.$btnUpload?.prop('disabled', !this.hasPermission(null, 'upload'));
// 批量下载按钮:需要全局权限且有选中文件
var canBatchDownload = this.hasPermission(null, 'download') && hasSelectedFiles;
this.$btnDownload?.prop('disabled', !canBatchDownload);
// 批量删除按钮:需要全局权限且有选中文件
var canBatchDelete = this.hasPermission(null, 'delete') && hasSelectedFiles;
this.$btnDelete?.prop('disabled', !canBatchDelete);
// 更新全选checkbox状态
var allSelected = this.files.length > 0 && this.selectedFiles.size === this.files.length;
this.$checkAll?.prop('checked', allSelected);
// 更新待上传数量
var pendingCount = this.files.filter(file => file.status === 'pending').length;
this.$pendingCount?.text(pendingCount);
// 更新文件项状态
this.$fileList.find('.g3-file-item').each(function () {
var $item = $(this);
var fileId = $item.data('id');
var file = self.files.find(f => f.id === fileId);
if (!file) return;
// 更新checkbox选中状态
var isSelected = self.selectedFiles.has(fileId);
$item.find('.g3-file-checkbox').prop('checked', isSelected);
// 更新按钮权限状态
var canDownload = self.hasPermission(file, 'download');
var canView = self.hasPermission(file, 'view');
var canDelete = self.hasPermission(file, 'delete');
$item.find('.g3-file-action.download')
.toggleClass('is-disabled', !canDownload);
$item.find('.g3-file-action.preview')
.toggleClass('is-disabled', !canView);
$item.find('.g3-file-delete-btn')
.toggleClass('is-disabled', !canDelete);
});
},
// =====================
// 获取选中文件
// =====================
getSelectedFiles: function () {
return this.files.filter(file => this.selectedFiles.has(file.id));
},
// =====================
// 设置文件
// =====================
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;
},
// =====================
// 更新状态
// =====================
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;
},
// =====================
// 权限判断(唯一入口)
// =====================
hasPermission: function (file, action) {
var globalPermission = this.options.globalPermission;
var filePermissionResolver = this.options.filePermissionResolver;
// 1. 优先判断单文件权限解析函数
if (typeof filePermissionResolver === 'function') {
var fileResult = filePermissionResolver({ file: file, action: action });
if (fileResult !== undefined) {
return !!fileResult;
}
}
// 2. 判断全局权限
if (typeof globalPermission === 'function') {
return !!globalPermission({ file: file, action: action });
} else if (typeof globalPermission === 'object') {
return !!globalPermission[action];
}
// 默认返回 true
return true;
},
// =====================
// 更新权限配置
// =====================
updatePermissions: function (config) {
if (!config) return;
if (config.globalPermission !== undefined) {
this.options.globalPermission = config.globalPermission;
}
if (config.filePermissionResolver !== undefined) {
this.options.filePermissionResolver = config.filePermissionResolver;
}
this.updateUI();
},
// =====================
// 销毁
// =====================
destroy: function () {
this.$container.off();
// 清理ObjectURL
this.objectUrls.forEach((url) => {
try {
URL.revokeObjectURL(url);
} catch (e) {
console.warn('释放ObjectURL失败:', e);
}
});
this.objectUrls.clear();
// 清理引用
this.files = [];
this.selectedFiles.clear();
this.$container.empty();
console.log('G3File 组件已销毁');
}
};
// ==========================
// jQuery 插件封装
// ==========================
$.fn.G3File = function (options) {
var args = Array.prototype.slice.call(arguments, 1);
var instance;
this.each(function () {
var $this = $(this);
instance = $this.data('G3File');
if (!instance) {
instance = new G3File(this, options);
$this.data('G3File', instance);
} else if (typeof options === 'string') {
var method = options;
if (instance[method]) {
instance[method].apply(instance, args);
}
}
});
return instance || this;
};
})(jQuery);