Files
workspace/code/file-preview/g3-file/index.js
T
2026-04-28 17:29:53 +08:00

2384 lines
75 KiB
JavaScript

(function ($) {
"use strict";
// ============================================================================
// G3File 构造函数
// ============================================================================
function G3File(container, options) {
// 实例隔离
this.$container = $(container);
this.options = $.extend({}, this.defaults, options);
this.instanceId = this.generateUniqueId();
this.files = [];
this.selectedFiles = new Set();
this.objectUrls = new Map();
this.language =
this.languages[this.options.language] || this.languages["zh-CN"];
// 操作菜单状态
this.actionMenuState = {
open: false,
fileId: null,
anchorEl: null,
items: [],
};
// 当前任务(单个任务实例)
this.currentTask = null;
// UI锁定状态
this.uiLocked = false;
// 阿里云 OSS 凭证缓存
this.aliyunKeyCache = null;
// 预览相关状态
this.previewIndex = 0;
this.init();
}
// ============================================================================
// 原型方法
// ============================================================================
G3File.prototype = {
// ============================================================================
// 配置和常量
// ============================================================================
/**
* 默认配置
*/
defaults: {
/**
* 初始化时的文件数据
* @typedef {Object} FileItem
* @property {string} _id - 文件唯一标识符
* @property {string} name - 文件名
* @property {number} size - 文件大小(字节)
* @property {string} uploadDate - 上传日期(ISO格式)
* @property {string} url - 文件访问URL
* @property {File|null} file - 本地File对象(仅待上传文件有)
* @property {string} status - 文件状态:pending/uploading/uploaded/failed
* @type {FileItem[]}
*/
data: [],
mode: "aliyun",
onDelete: null,
onDownload: null,
onSelect: null,
onUpload: null,
onUploadSuccess: null,
onUploadError: null,
onUploadComplete: null,
onPreview: null,
getActionMenu: null,
language: "zh-CN",
maxFileSize: 10 * 1024 * 1024,
// 上传配置
uploadConfig: {
aliyun: {
// 阿里云配置是可选的
// getAliyunKey 是必须的,用于动态获取阿里云 OSS 上传凭证
getAliyunKey: null, // 异步函数,返回 { accessid, callback, dir, expire, host, policy, signature }
},
customUpload: null, // 自定义上传方法
},
// 全局权限(支持对象或函数)
globalPermission: {
select: true,
upload: true,
download: true,
delete: true,
view: true,
},
// 单文件权限解析函数(仅支持函数)
filePermissionResolver: null,
},
/**
* 多语言配置
*/
languages: {
"zh-CN": {
selectAll: "全选",
download: "下载",
downloadBatch: "批量下载",
delete: "删除",
upload: "上传",
select: "选择",
more: "更多",
confirmDelete: "确定要删除选中的 {count} 个文件吗?",
downloading: "准备下载 {count} 个文件",
deleting: "删除中...",
name: "名称",
size: "大小",
date: "日期",
pending: "未上传",
selectFiles: "选择文件",
emptyTip1: "拖拽文件到这里 …",
emptyTip2: "支持多文件同时上传/下载",
emptyTip3: "支持粘贴图片上传",
fileSizeError: "文件大小超过限制",
preview: "预览",
filePreview: "文件预览",
fileName: "文件名",
fileSize: "文件大小",
uploadDate: "上传时间",
fileStatus: "状态",
notUploaded: "未上传",
uploaded: "已上传",
cannotPreview: "该类型文件不支持预览",
previewCount: "{current}/{total}",
uploadToPreview: "请上传后预览",
downloadingStatus: "下载中:",
downloadingFile: "正在下载:",
downloadComplete: "下载完成",
downloadResult: "共{total}文件,{success}成功,{failed}失败",
close: "关闭",
batchDownload: "批量下载",
batchUpload: "批量上传",
error: "错误",
downloadError: "下载失败,请检查文件是否有效",
toast: {
success: "成功",
error: "错误",
info: "提示",
warning: "警告",
},
task: {
total: "总计",
success: "成功",
failed: "失败",
currentItem: "当前项",
close: "关闭",
},
},
"en-US": {
selectAll: "Select All",
download: "Download",
downloadBatch: "Batch Download",
delete: "Delete",
upload: "Upload",
select: "Select",
more: "More",
confirmDelete:
"Are you sure you want to delete {count} selected file(s)?",
downloading: "Preparing to download {count} file(s)",
deleting: "Deleting...",
name: "Name",
size: "Size",
date: "Date",
pending: "Pending upload",
selectFiles: "Select Files",
emptyTip1: "Drag files here ...",
emptyTip2: "Supports multiple file upload/download",
emptyTip3: "Supports pasting images to upload",
fileSizeError: "File size exceeds limit",
preview: "Preview",
filePreview: "File Preview",
fileName: "File Name",
fileSize: "File Size",
uploadDate: "Upload Date",
fileStatus: "Status",
notUploaded: "Not uploaded",
uploaded: "Uploaded",
cannotPreview: "This file type is not previewable",
previewCount: "{current}/{total}",
uploadToPreview: "Please upload to preview",
downloadingStatus: "Downloading:",
downloadingFile: "Downloading:",
downloadComplete: "Download complete",
downloadResult:
"Total {total} files, {success} succeeded, {failed} failed",
close: "Close",
batchDownload: "Batch Download",
batchUpload: "Batch Upload",
error: "Error",
downloadError: "Download failed, please check if the file is valid",
toast: {
success: "Success",
error: "Error",
info: "Info",
warning: "Warning",
},
task: {
total: "Total",
success: "Success",
failed: "Failed",
currentItem: "Current",
close: "Close",
},
},
},
/**
* 静态图标映射
*/
staticIconMap: {
download: `
<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">
<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"><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>
`,
},
// ============================================================================
// 初始化和核心方法
// ============================================================================
/**
* 初始化
* @returns {G3File} 实例
*/
init: function () {
var self = this;
self.render();
self.bindEvents();
self.updateUI();
if (self.options.data && self.options.data.length) {
self.setFiles(self.options.data);
}
self.preloadAliyunKey();
return this;
},
/**
* 预加载阿里云 OSS 凭证(初始化时调用)
*/
preloadAliyunKey: async function () {
if (this.options.mode !== "aliyun") {
return;
}
const aliyunConfig = this.options.uploadConfig.aliyun;
if (!aliyunConfig || typeof aliyunConfig.getAliyunKey !== "function") {
return;
}
try {
const keyData = await aliyunConfig.getAliyunKey();
if (keyData && keyData.accessid && keyData.host) {
this.aliyunKeyCache = {
data: keyData,
expireTime: keyData.expire ? keyData.expire * 1000 : Date.now() + 3600000,
};
console.log("[G3File] 阿里云 OSS 凭证预加载成功");
}
} catch (error) {
console.warn("[G3File] 阿里云 OSS 凭证预加载失败:", error);
}
},
/**
* 检查并获取有效的阿里云 OSS 凭证
* @returns {Object|null} 阿里云凭证数据
*/
getValidAliyunKey: async function () {
const now = Date.now();
if (this.aliyunKeyCache) {
if (now < this.aliyunKeyCache.expireTime - 60000) {
return this.aliyunKeyCache.data;
}
console.log("[G3File] 阿里云 OSS 凭证即将过期,自动刷新");
}
const aliyunConfig = this.options.uploadConfig.aliyun;
if (!aliyunConfig || typeof aliyunConfig.getAliyunKey !== "function") {
throw new Error("阿里云上传配置不完整,缺少 getAliyunKey 函数");
}
try {
const keyData = await aliyunConfig.getAliyunKey();
if (!keyData || !keyData.accessid || !keyData.host) {
throw new Error("阿里云凭证获取失败");
}
this.aliyunKeyCache = {
data: keyData,
expireTime: keyData.expire ? keyData.expire * 1000 : Date.now() + 3600000,
};
console.log("[G3File] 阿里云 OSS 凭证获取成功");
return keyData;
} catch (error) {
console.error("[G3File] 获取阿里云 OSS 凭证失败:", error);
throw error;
}
},
/**
* 生成文件 key(UUID + 原扩展名)
* @param {Object} keyData - 阿里云凭证数据
* @param {string} filename - 原始文件名
* @returns {string} 生成的 key
*/
generateFileKey: function (keyData, filename) {
const dir = keyData.dir || "";
const ext = filename.slice(filename.lastIndexOf(".")) || "";
const uuid = this.generateUniqueId().replace("file_", "");
return dir + uuid + ext;
},
// ============================================================================
// 工具方法
// ============================================================================
/**
* 生成唯一ID
* @returns {string} 唯一ID
*/
generateUniqueId: function () {
return "file_" + Math.random().toString(36).slice(2) + Date.now();
},
/**
* 获取文件图标
* @param {string} filename - 文件名
* @returns {string} 图标HTML
*/
getFileIcon: function (filename) {
var ext = filename.split(".").pop().toLowerCase();
var iconMap = {
pdf: '<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;
},
/**
* 压缩图片
* @param {File} file - 图片文件
* @param {Object} options - 压缩选项
* @returns {Promise<File|Blob>} 压缩后的文件
*/
compressImage: async function (file, options = {}) {
const {
minSize = 300 * 1024,
maxSize = 1.2 * 1024 * 1024,
maxWidth = 1920,
maxHeight = 1920,
} = options;
if (!file || !file.type.startsWith("image/")) return file;
const originSize = file.size;
if (originSize <= maxSize) {
return file;
}
const img = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = reject;
image.src = reader.result;
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
let w = img.width;
let h = img.height;
const scale = Math.min(1, maxWidth / w, maxHeight / h);
w = Math.round(w * scale);
h = Math.round(h * scale);
canvas.width = w;
canvas.height = h;
ctx.drawImage(img, 0, 0, w, h);
const getBlob = (q) =>
new Promise((resolve) => {
canvas.toBlob((b) => resolve(b), "image/jpeg", q);
});
let quality = 0.92;
if (originSize > 4 * 1024 * 1024) {
quality = 0.88;
}
const blob = await getBlob(quality);
if (blob && blob.size < minSize) {
const safe = await getBlob(0.97);
return safe || file;
}
return blob || file;
},
/**
* 判断是否为图片文件
* @param {string} filename - 文件名
* @returns {boolean} 是否为图片文件
*/
isImageFile: function (filename) {
if (!filename || typeof filename !== "string") return false;
const ext = filename.slice(filename.lastIndexOf(".") + 1).toLowerCase();
const imageExts = new Set(["jpg", "jpeg", "png", "gif", "bmp", "webp"]);
return imageExts.has(ext);
},
/**
* 格式化文件大小
* @param {number} bytes - 文件大小(字节)
* @returns {string} 格式化后的大小
*/
formatFileSize: function (bytes) {
var numBytes = typeof bytes === 'string' ? parseFloat(bytes) : bytes;
if (numBytes === null || numBytes === undefined || isNaN(numBytes) || numBytes <= 0) {
return "-";
}
var k = 1024;
var sizes = ["B", "KB", "MB", "GB", "TB"];
var i = Math.floor(Math.log(numBytes) / Math.log(k));
i = Math.max(0, Math.min(i, sizes.length - 1));
return (numBytes / Math.pow(k, i)).toFixed(2) + " " + sizes[i];
},
/**
* 格式化日期
* @param {string} dateStr - 日期字符串
* @returns {string} 格式化后的日期
*/
formatDate: function (dateStr) {
if (!dateStr) return "-";
var date = new Date(dateStr);
if (isNaN(date.getTime())) {
return "-";
}
var year = date.getFullYear();
var month = ("0" + (date.getMonth() + 1)).slice(-2);
var day = ("0" + date.getDate()).slice(-2);
var hours = ("0" + date.getHours()).slice(-2);
var minutes = ("0" + date.getMinutes()).slice(-2);
var seconds = ("0" + date.getSeconds()).slice(-2);
return (
year +
"-" +
month +
"-" +
day +
" " +
hours +
":" +
minutes +
":" +
seconds
);
},
/**
* 获取唯一文件名
* @param {string} fileName - 原始文件名
* @param {Set} files - 已存在的文件名集合
* @returns {string} 唯一文件名
*/
getUniqueFileName: function (fileName, files) {
const originalFileName = fileName;
let uniqueFileName = fileName;
let counter = 1;
while (files.has(uniqueFileName)) {
const fileNameWithoutExtension = originalFileName.replace(
/\.[^/.]+$/,
"",
);
const extension = originalFileName.slice(
originalFileName.lastIndexOf("."),
);
uniqueFileName = `${fileNameWithoutExtension} (${counter})${extension}`;
counter++;
}
return uniqueFileName;
},
/**
* 获取文件Blob
* @param {string} url - 文件URL
* @returns {Promise<ArrayBuffer>} 文件二进制数据
*/
getFileBlob: async function (url) {
if (!url) {
throw new Error("无效的文件URL");
}
let fetchUrl = url;
if (this.options.mode === "aliyun" && this.aliyunKeyCache) {
const { accessid, policy, signature } = this.aliyunKeyCache.data;
if (accessid && policy && signature) {
const separator = url.includes("?") ? "&" : "?";
fetchUrl = `${url}${separator}OSSAccessKeyId=${accessid}&policy=${policy}&Signature=${signature}`;
}
}
const res = await fetch(fetchUrl);
if (!res.ok) {
throw new Error("网络请求失败,状态码:" + res.status);
}
const buffer = await res.arrayBuffer();
return buffer;
},
// ============================================================================
// UI 渲染相关方法
// ============================================================================
/**
* 渲染
* @returns {G3File} 实例
*/
render: function () {
const html = `
<div class="g3-file-container">
<div class="g3-file-left-panel">
<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>
<div class="g3-file-preview-panel">
<div class="g3-preview-toolbar">
<div class="preview-nav">
<button class="preview-btn preview-prev" disabled>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 19l-7-7 7-7"/><path d="M8 12h11"/></svg>
</button>
<button class="preview-btn preview-next" disabled>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5l7 7-7 7"/><path d="M16 12H3"/></svg>
</button>
</div>
<div class="preview-info">
<span class="preview-name">${this.language.selectFiles}</span>
<span class="preview-counter">0/0</span>
</div>
</div>
<div class="g3-preview-content">
<div class="preview-placeholder">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="m15 15-3 3"/><circle cx="12" cy="12" r="3"/></svg>
<h3>${this.language.selectFiles}</h3>
<p>${this.language.emptyTip1}</p>
</div>
</div>
</div>
<div class="g3-global-menu"></div>
</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");
// 预览相关DOM引用
this.$previewPanel = this.$container.find(".g3-file-preview-panel");
this.$previewToolbar = this.$container.find(".g3-preview-toolbar");
this.$previewContent = this.$container.find(".g3-preview-content");
this.$btnPrev = this.$container.find(".preview-prev");
this.$btnNext = this.$container.find(".preview-next");
this.$previewName = this.$container.find(".preview-name");
this.$previewCounter = this.$container.find(".preview-counter");
return this;
},
/**
* 渲染操作菜单
*/
renderActionMenu: function () {
var actionMenu = this.$container.find(".g3-global-menu");
if (!actionMenu.length) {
this.$container.append('<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);
actionMenu
.find(".g3-action-menu-item")
.off("click")
.on("click", function (e) {
e.stopPropagation();
var idx = $(this).index();
var item = actionMenuState.items[idx];
var isDisabled = false;
if (typeof item.disabled === "function") {
isDisabled = item.disabled(
self.getFileById(actionMenuState.fileId),
);
} else if (item.disabled !== undefined) {
isDisabled = item.disabled;
}
if (isDisabled) return;
item.onClick &&
item.onClick(self.getFileById(actionMenuState.fileId));
self.closeActionMenu();
});
var $anchor = $(actionMenuState.anchorEl).closest(".g3-file-action.more");
var rect = $anchor[0].getBoundingClientRect();
var $mainContainer = this.$container.find(".main");
var mainRect = $mainContainer[0].getBoundingClientRect();
var menuWidth = 160;
var itemHeight = 36;
var menuHeight = actionMenuState.items.length * itemHeight;
var top = rect.bottom;
var left = rect.right - menuWidth;
var spaceBottom = mainRect.bottom - rect.bottom; // main 容器底部到锚点的距离
var spaceTop = rect.top - mainRect.top; // 锚点到 main 容器顶部的距离
var openUp = spaceBottom < menuHeight && spaceTop > menuHeight;
if (openUp) {
top = rect.top - menuHeight; // 在锚点上方显示
}
// 确保菜单不超出 main 容器左右边界
if (left < mainRect.left) {
left = mainRect.left;
}
if (left + menuWidth > mainRect.right) {
left = mainRect.right - menuWidth;
}
// 确保菜单不超出 main 容器上下边界
if (top < mainRect.top) {
top = mainRect.top;
}
if (top + menuHeight > mainRect.bottom) {
top = mainRect.bottom - menuHeight;
}
actionMenu.css({
top: top,
left: left,
});
requestAnimationFrame(function () {
actionMenu.addClass("is-open");
});
},
/**
* 创建文件项
* @param {Object} file - 文件对象
* @returns {string} 文件项HTML
*/
createFileItem: function (file) {
const downloadDisabled = !this.hasPermission(file, "download");
const previewDisabled = !this.hasPermission(file, "view");
const deleteDisabled = !this.hasPermission(file, "delete");
let content = "";
if (this.isImageFile(file.name)) {
content = `
<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);
}
let statusBadge = "";
switch (file.status) {
case "pending":
statusBadge = `
<div class="g3-file-pending-badge">
${this.language.pending}
</div>
`;
break;
case "uploading":
statusBadge = `
<div class="g3-file-uploading-badge">
上传中
</div>
`;
break;
case "failed":
statusBadge = `
<div class="g3-file-failed-badge">
上传失败
</div>
`;
break;
}
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>
${statusBadge}
<div class="g3-file-delete-btn ${deleteDisabled ? 'is-disabled' : ''}">
${this.staticIconMap.delete}
</div>
<div class="g3-file-info">
<span class="g3-file-name">${file.name}</span>
<span>${typeof file.size === 'string' ? (file.size || '-') : 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" : ""}" title="${this.language.preview}">
${this.staticIconMap.preview}
</div>
<div class="g3-file-action download ${downloadDisabled ? "is-disabled" : ""}" title="${this.language.download}">
${this.staticIconMap.download}
</div>
<div class="g3-file-action more" title="${this.language.more}">
${this.staticIconMap.more}
</div>
</div>
</div>
`;
},
/**
* 渲染文件列表
*/
renderFileList: function () {
if (this.files.length === 0) {
this.$fileList.hide();
this.$emptyTip.show();
return;
}
this.$fileList.show();
this.$emptyTip.hide();
var html = "";
var self = this;
this.files.forEach(function (file) {
html += self.createFileItem(file);
});
this.$fileList.html(html);
this.bindFileItemEvents();
},
/**
* 更新UI
*/
updateUI: function () {
var self = this;
var hasSelectedFiles = this.selectedFiles.size > 0;
// 更新全局按钮状态
this.$btnSelect?.prop("disabled", !this.hasPermission(null, "select"));
this.$btnUpload?.prop("disabled", !this.hasPermission(null, "upload"));
// 批量下载按钮:需要全局权限且有选中文件
var canBatchDownload =
this.hasPermission(null, "download") && hasSelectedFiles;
this.$btnDownload?.prop("disabled", !canBatchDownload);
// 批量删除按钮:需要全局权限且有选中文件
var canBatchDelete =
this.hasPermission(null, "delete") && hasSelectedFiles;
this.$btnDelete?.prop("disabled", !canBatchDelete);
// 更新全选checkbox状态
var allSelected =
this.files.length > 0 && this.selectedFiles.size === this.files.length;
this.$checkAll?.prop("checked", allSelected);
// 更新待上传数量
var pendingCount = this.files.filter(
(file) => file.status === "pending",
).length;
this.$pendingCount?.text(pendingCount);
// 更新文件项状态
this.$fileList.find(".g3-file-item").each(function () {
var $item = $(this);
var fileId = $item.data("id");
var file = self.files.find((f) => f._id === fileId);
if (!file) return;
// 更新checkbox选中状态
var isSelected = self.selectedFiles.has(fileId);
$item.find(".g3-file-checkbox").prop("checked", isSelected);
// 更新按钮权限状态
var canDownload = self.hasPermission(file, "download");
var canView = self.hasPermission(file, "view");
var canDelete = self.hasPermission(file, "delete");
$item
.find(".g3-file-action.download")
.toggleClass("is-disabled", !canDownload);
$item
.find(".g3-file-action.preview")
.toggleClass("is-disabled", !canView);
$item
.find(".g3-file-delete-btn")
.toggleClass("is-disabled", !canDelete);
});
// 更新预览区域
self.updatePreview();
},
/**
* 获取已上传的文件列表(仅status为uploaded的文件)
*/
getUploadedFiles: function () {
return this.files;
},
isPreviewableFile: function (fileName) {
var nonPreviewableExts = [
".rar", ".jar", ".exe", ".zip", ".7z", ".tar", ".gz", ".bz2", ".iso",
".dll", ".bin", ".msi", ".cab", ".rpm", ".deb", ".apk", ".ipa",
".dat", ".bak", ".tmp", ".log", ".cache", ".db", ".sqlite"
];
var ext = fileName.toLowerCase().substring(fileName.lastIndexOf('.'));
return !nonPreviewableExts.includes(ext);
},
/**
* 预览上一个文件
*/
previewPrev: function () {
var uploadedFiles = this.getUploadedFiles();
if (uploadedFiles.length === 0) return;
if (this.previewIndex > 0) {
this.previewIndex--;
} else {
this.previewIndex = uploadedFiles.length - 1;
}
this.updatePreview();
},
/**
* 预览下一个文件
*/
previewNext: function () {
var uploadedFiles = this.getUploadedFiles();
if (uploadedFiles.length === 0) return;
if (this.previewIndex < uploadedFiles.length - 1) {
this.previewIndex++;
} else {
this.previewIndex = 0;
}
this.updatePreview();
},
base64Encode: function (str) {
return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function (match, p1) {
return String.fromCharCode('0x' + p1);
}));
},
/**
* 更新预览区域显示
*/
updatePreview: function () {
var allFiles = this.getUploadedFiles();
var total = allFiles.length;
if (total === 0) {
this.$previewContent.html(`
<div class="preview-placeholder">
<p>请选择文件</p>
</div>
`);
this.$previewName.text(this.language.selectFiles);
this.$previewCounter.text("0/0");
this.$btnPrev.prop("disabled", true);
this.$btnNext.prop("disabled", true);
return;
}
if (this.previewIndex >= total) {
this.previewIndex = total - 1;
}
if (this.previewIndex < 0) {
this.previewIndex = 0;
}
var currentFile = allFiles[this.previewIndex];
var isUploaded = currentFile.status === "uploaded";
var isPreviewable = this.isPreviewableFile(currentFile.name);
var fileUrl = currentFile.url && currentFile.url !== '' ? currentFile.url : null;
this.$btnPrev.prop("disabled", this.previewIndex === 0);
this.$btnNext.prop("disabled", this.previewIndex === total - 1);
this.$previewName.text(currentFile.name);
var counterText = this.language.previewCount.replace("{current}", this.previewIndex + 1).replace("{total}", total);
this.$previewCounter.text(counterText);
var previewContent = "";
if (isUploaded && fileUrl && isPreviewable) {
var previewUrl = this.options.previewUrl || '';
var fullPreviewUrl = previewUrl ? previewUrl + this.base64Encode(fileUrl) : fileUrl;
previewContent = `
<iframe
src="${fullPreviewUrl}"
class="preview-iframe"
frameborder="0"
allowfullscreen
></iframe>
`;
} else {
var tipsHtml = '';
if (!isUploaded) {
tipsHtml = `<p class="upload-tip">请上传后预览</p>`;
} else if (!isPreviewable) {
tipsHtml = `<p class="cannot-preview">请下载后预览</p>`;
} else if (!fileUrl) {
tipsHtml = `<p class="url-error">链接无效请检查链接</p>`;
}
previewContent = `
<div class="preview-placeholder">
<p>${currentFile.name}</p>
${tipsHtml}
</div>
`;
}
this.$previewContent.html(previewContent);
},
setPreviewFile: function (fileId) {
var uploadedFiles = this.getUploadedFiles();
var index = uploadedFiles.findIndex(function (f) { return f._id === fileId; });
if (index >= 0) {
this.previewIndex = index;
this.updatePreview();
}
},
// ============================================================================
// 事件绑定相关方法
// ============================================================================
/**
* 绑定事件
*/
bindEvents: function () {
var self = this;
// 选择全部
this.$checkAll.off("change").on("change", function () {
var checked = $(this).is(":checked");
self.selectedFiles.clear();
if (checked) {
self.files.forEach(function (file) {
self.selectedFiles.add(file._id);
});
}
self.updateUI();
});
// 批量下载
this.$btnDownload.off("click").on("click", function () {
var selectedFiles = self.getSelectedFiles();
if (selectedFiles.length === 0) return;
self.handleDownload(selectedFiles, { batch: true });
});
// 批量删除
this.$btnDelete.off("click").on("click", function () {
var selectedFiles = self.getSelectedFiles();
if (selectedFiles.length === 0) return;
self.handleDelete(selectedFiles);
});
// 选择文件
this.$btnSelect.off("click").on("click", function () {
self.$fileInput.click();
});
// 上传
this.$btnUpload.off("click").on("click", function () {
var pendingFiles = self.files.filter(
(file) => file.status === "pending",
);
if (typeof self.options.onUpload === "function") {
self.options.onUpload(pendingFiles);
} else {
// 使用新的上传逻辑
self.handleUpload(self.files);
}
});
// 文件选择
this.$fileInput.off("change").on("change", function (e) {
var files = e.target.files;
if (!files || files.length === 0) return;
self.handleFileSelect(files);
// 重置文件输入框
self.$fileInput.val("");
});
// 点击其他地方关闭菜单
$(document)
.off("click.g3file_actionmenu")
.on("click.g3file_actionmenu", function (e) {
if (
!$(e.target).closest(".g3-file-action.more").length &&
!$(e.target).closest(".g3-global-menu").length
) {
self.closeActionMenu();
}
});
// 拖拽文件
const $container = this.$container;
$container
.off("dragenter dragleave dragover drop")
.on("dragenter", function (e) {
e.preventDefault();
e.stopPropagation();
$container.addClass("drag-over");
})
.on("dragover", function (e) {
e.preventDefault();
e.stopPropagation();
$container.addClass("drag-over");
})
.on("dragleave", function (e) {
e.preventDefault();
e.stopPropagation();
if (e.target === $container[0]) {
$container.removeClass("drag-over");
}
})
.on("drop", function (e) {
e.preventDefault();
e.stopPropagation();
$container.removeClass("drag-over");
var files = e.originalEvent.dataTransfer.files;
if (files && files.length > 0) {
self.handleFileSelect(files);
}
});
// 粘贴文件
$(document)
.off("paste.g3file")
.on("paste.g3file", function (e) {
var clipboardData = e.originalEvent.clipboardData;
var files = clipboardData.files;
if (files && files.length > 0) {
e.preventDefault();
self.handleFileSelect(files);
}
});
// 预览上一个
this.$btnPrev.off("click").on("click", function () {
self.previewPrev();
});
// 预览下一个
this.$btnNext.off("click").on("click", function () {
self.previewNext();
});
},
/**
* 绑定文件项事件
*/
bindFileItemEvents: function () {
var self = this;
// 预览
this.$fileList
.off("click", ".preview")
.on("click", ".preview", function (e) {
e.preventDefault();
e.stopPropagation();
var id = $(this).closest(".g3-file-item").data("id");
var file = self.files.find((f) => f._id === id);
if (!file) return;
self.setPreviewFile(id);
if (typeof self.options.onPreview === "function") {
self.options.onPreview([file]);
}
});
// 下载
this.$fileList
.off("click", ".download")
.on("click", ".download", function (e) {
e.preventDefault();
e.stopPropagation();
var id = $(this).closest(".g3-file-item").data("id");
var file = self.files.find((f) => f._id === id);
if (!file) return;
self.handleDownload([file], { batch: false });
});
// 复选框
this.$fileList
.off("change", ".g3-file-checkbox")
.on("change", ".g3-file-checkbox", function () {
var id = $(this).closest(".g3-file-item").data("id");
if ($(this).is(":checked")) {
self.selectedFiles.add(id);
} else {
self.selectedFiles.delete(id);
}
self.updateUI();
});
// 删除
this.$fileList
.off("click", ".g3-file-delete-btn")
.on("click", ".g3-file-delete-btn", function (e) {
e.preventDefault();
e.stopPropagation();
var id = $(this).closest(".g3-file-item").data("id");
var file = self.files.find((f) => f._id === id);
if (!file) return;
self.handleDelete([file]);
});
// 文件项点击
this.$fileList
.off("click", ".g3-file-item")
.on("click", ".g3-file-item", function (e) {
if (
$(e.target).closest(
".g3-file-action, .g3-file-checkbox, .g3-file-delete-btn",
).length
) {
return;
}
var id = $(this).data("id");
var file = self.files.find((f) => f._id === id);
if (!file) return;
if (typeof self.options.onPreview === "function") {
self.options.onPreview([file]);
}
self.setPreviewFile(id);
});
// 更多按钮
this.$fileList.off("click", ".more").on("click", ".more", function (e) {
e.preventDefault();
e.stopPropagation();
var $el = $(this).closest(".g3-file-action");
var id = $el.closest(".g3-file-item").data("id");
var file = self.getFileById(id);
if (!file) return;
if (
self.actionMenuState.open &&
self.actionMenuState.fileId === file._id
) {
self.closeActionMenu();
return;
}
self.openActionMenu($el, file);
});
},
// ============================================================================
// 文件处理相关方法
// ============================================================================
/**
* 处理文件选择
* @param {FileList} files - 文件列表
*/
handleFileSelect: async function (files) {
var self = this;
var maxSize = this.options.maxFileSize || 10 * 1024 * 1024;
var validFiles = [];
var invalidFiles = [];
for (var i = 0; i < files.length; i++) {
var file = files[i];
if (file.size > maxSize) {
invalidFiles.push(file);
} else {
validFiles.push(file);
}
}
if (invalidFiles.length > 0) {
var errorMsg = this.language.fileSizeError || "文件大小超过限制";
console.warn(
errorMsg + ": " + invalidFiles.map((f) => f.name).join(", "),
);
}
if (validFiles.length > 0) {
var tempFiles = [];
for (var j = 0; j < validFiles.length; j++) {
var file = validFiles[j];
let url = "";
let compressedFile = file;
if (self.isImageFile(file.name)) {
if (typeof self.options.compressImage === "function") {
try {
compressedFile = await self.options.compressImage(file);
} catch (e) {
console.warn("外部压缩算法执行失败,使用内部压缩:", e);
compressedFile = await self.compressImage(file);
}
} else {
compressedFile = await self.compressImage(file);
}
url = URL.createObjectURL(compressedFile);
self.objectUrls.set(self.generateUniqueId(), url);
}
tempFiles.push({
_id: self.generateUniqueId(),
name: file.name,
size: compressedFile.size,
uploadDate: new Date().toISOString(),
url: url,
file: compressedFile,
status: "pending",
});
}
self.setFiles(self.files.concat(tempFiles));
if (typeof self.options.onSelect === "function") {
self.options.onSelect(validFiles);
}
self.autoScrollToBottom();
}
},
/**
* 自动滚动到底部
*/
autoScrollToBottom: function () {
this.$fileList.scrollTop(this.$fileList[0].scrollHeight);
},
/**
* 处理下载
* @param {Array} files - 文件数组
* @param {Object} options - 下载选项
*/
handleDownload: function (files, options) {
if (typeof this.options.onDownload === "function") {
this.options.onDownload(files, options);
return;
}
if (options.batch) {
this.internalDownloadBatch(files);
} else {
this.internalDownload(files[0]);
}
},
/**
* 处理删除
* @param {Array} files - 文件数组
*/
handleDelete: function (files) {
var self = this;
if (typeof this.options.onDelete === "function") {
this.options.onDelete(files, function (success) {
if (success) {
self.internalDelete(files);
}
});
return;
}
this.internalDelete(files);
},
/**
* 内部下载
* @param {Object} file - 文件对象
*/
internalDownload: async function (file) {
if (!file) {
console.warn("下载失败:文件对象无效", file);
this.showToast({
title: this.language.error || "错误",
message: this.language.downloadError || "下载失败,请检查文件是否有效",
type: "error",
duration: 3000,
});
return;
}
console.log("开始下载文件:", file.name);
try {
let blob;
if (file.status === "uploaded" && file.url) {
const buffer = await this.getFileBlob(file.url);
blob = new Blob([buffer]);
} else if (file.file) {
blob = file.file;
} else {
console.warn("下载失败:文件没有URL或本地文件对象", file);
this.showToast({
title: this.language.error || "错误",
message: this.language.downloadError || "下载失败,请检查文件是否有效",
type: "error",
duration: 3000,
});
return;
}
saveAs(blob, file.name || "未命名文件");
} catch (err) {
console.error("文件下载失败:", err);
this.showToast({
title: this.language.error || "错误",
message: this.language.downloadError || "下载失败,请检查文件是否有效",
type: "error",
duration: 3000,
});
}
},
/**
* 内部批量下载
* @param {Array} files - 文件数组
*/
internalDownloadBatch: async function (files) {
if (!files || files.length === 0) return;
const zip = new JSZip();
const addedFiles = new Set();
const total = files.length;
let successCount = 0;
let failedCount = 0;
const concurrency = 3;
const taskId = this.generateUniqueId();
this.startTask({
id: taskId,
title: this.language.batchDownload || "批量下载",
total: total,
current: 0,
currentItem: "",
});
const downloadOne = async (item) => {
try {
this.updateTask({
id: taskId,
current: successCount + failedCount,
currentItem: item.name,
});
let content;
if (item.status === "uploaded" && item.url) {
content = await this.getFileBlob(item.url);
} else if (item.file) {
content = await item.file.arrayBuffer();
} else {
console.warn("跳过失败文件:", item.name, "没有URL或本地文件对象");
failedCount++;
return;
}
const uniqueFileName = this.getUniqueFileName(item.name, addedFiles);
zip.file(uniqueFileName, content);
addedFiles.add(uniqueFileName);
successCount++;
} catch (err) {
console.warn("跳过失败文件:", item.name, err);
failedCount++;
}
};
try {
const filesCopy = [...files];
const runner = async () => {
while (filesCopy.length) {
const item = filesCopy.shift();
await downloadOne(item);
}
};
await Promise.all(Array.from({ length: concurrency }, runner));
this.updateTask({
id: taskId,
current: total,
});
const blob = await zip.generateAsync({ type: "blob" });
const now = new Date();
const pad = (n) => (n < 10 ? "0" + n : n);
const fileName =
now.getFullYear() +
pad(now.getMonth() + 1) +
pad(now.getDate()) +
pad(now.getHours()) +
pad(now.getMinutes());
saveAs(blob, fileName + ".zip");
this.finishTask({
id: taskId,
total: total,
success: successCount,
failed: failedCount,
});
if (failedCount === 0) {
this.showToast({
title: this.language.downloadComplete || "下载完成",
message: `${successCount}/${total}`,
type: "success",
duration: 3000,
});
} else {
this.showToast({
title: this.language.downloadComplete || "下载完成",
message: `${this.language.downloadResult || "共{total}文件,{success}成功,{failed}失败"}`
.replace("{total}", total)
.replace("{success}", successCount)
.replace("{failed}", failedCount),
type: failedCount === total ? "error" : "warning",
duration: 5000,
});
}
} catch (err) {
console.error("批量下载失败:", err);
failedCount = total - successCount;
this.finishTask({
id: taskId,
total: total,
success: successCount,
failed: failedCount,
});
this.showToast({
title: this.language.error || "错误",
message: this.language.downloadError || "下载失败,请检查文件是否有效",
type: "error",
duration: 5000,
});
}
},
/**
* 内部删除
* @param {Array} files - 文件数组
*/
internalDelete: function (files) {
var self = this;
files.forEach(function (file) {
// 清理本地临时连接
if (file.url && file.status === "pending") {
self.objectUrls.forEach((url, key) => {
if (url === file.url) {
try {
URL.revokeObjectURL(url);
self.objectUrls.delete(key);
} catch (e) {
console.warn("释放ObjectURL失败:", e);
}
}
});
}
self.selectedFiles.delete(file._id);
});
this.files = this.files.filter(
(f) => !files.find((file) => file._id === f._id),
);
this.renderFileList();
this.updateUI();
},
/**
* 更新文件状态
* @param {string} fileId - 文件ID
* @param {string} status - 新状态
* @param {Object} data - 附加数据
*/
updateFileStatus: function (fileId, status, data) {
const file = this.files.find((f) => f._id === fileId);
if (file) {
file.status = status;
if (data) {
Object.assign(file, data);
}
this.renderFileList();
this.updateUI();
}
},
/**
* 内部上传方法,支持阿里云 OSS 直传
* @param {Object} file - 文件对象
* @param {Function} callback - 上传完成回调
*/
internalUpload: async function (file, callback) {
const self = this;
try {
const keyData = await this.getValidAliyunKey();
const key = this.generateFileKey(keyData, file.name);
const formData = new FormData();
formData.append("OSSAccessKeyId", keyData.accessid);
formData.append("policy", keyData.policy);
formData.append("signature", keyData.signature);
formData.append("key", key);
formData.append("callback", keyData.callback);
formData.append("success_action_status", "200");
formData.append("file", file.file || file);
const response = await fetch(keyData.host, {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error(`上传失败,HTTP 状态码: ${response.status}`);
}
const result = await response.json();
const uploadedFile = {
...file,
status: "uploaded",
url: result.url || `${keyData.host}/${key}`,
uploadDate: new Date().toISOString(),
};
self.updateFileStatus(file._id, "uploaded", {
url: uploadedFile.url,
uploadDate: uploadedFile.uploadDate,
});
if (typeof self.options.onUploadSuccess === "function") {
self.options.onUploadSuccess(uploadedFile);
}
callback(null, uploadedFile);
} catch (error) {
console.error("[G3File] 上传失败:", error);
self.updateFileStatus(file._id, "failed");
if (typeof self.options.onUploadError === "function") {
self.options.onUploadError(file, error);
}
callback(error);
}
},
/**
* 处理上传逻辑
* @param {Array} files - 文件数组
*/
handleUpload: function (files) {
const self = this;
const customUpload = this.options.uploadConfig.customUpload;
let successCount = 0;
let failCount = 0;
// 过滤出未上传的文件
const pendingFiles = files.filter((file) => file.status === "pending");
if (pendingFiles.length === 0) {
// 触发上传完成回调
if (typeof self.options.onUploadComplete === "function") {
self.options.onUploadComplete(0, 0);
}
return;
}
const total = pendingFiles.length;
const taskId = this.generateUniqueId();
// 启动上传任务
this.startTask({
id: taskId,
title: this.language.batchUpload || "批量上传",
total: total,
current: 0,
currentItem: "",
});
// 逐个上传文件
pendingFiles.forEach((file) => {
// 更新文件状态为上传中
self.updateFileStatus(file._id, "uploading");
if (typeof customUpload === "function") {
// 使用自定义上传方法
customUpload(file)
.then((uploadedFile) => {
// 更新任务进度
self.updateTask({
id: taskId,
current: successCount + failCount,
currentItem: file.name,
});
// 更新文件状态
self.updateFileStatus(file._id, "uploaded", uploadedFile);
// 触发上传成功回调
if (typeof self.options.onUploadSuccess === "function") {
self.options.onUploadSuccess(uploadedFile);
}
successCount++;
checkComplete();
})
.catch((error) => {
// 更新任务进度
self.updateTask({
id: taskId,
current: successCount + failCount,
currentItem: file.name,
});
// 更新文件状态为失败
self.updateFileStatus(file._id, "failed");
// 触发上传失败回调
if (typeof self.options.onUploadError === "function") {
self.options.onUploadError(file, error);
}
failCount++;
checkComplete();
});
} else if (this.options.mode === "aliyun") {
// 使用阿里云内部上传方法
self.internalUpload(file, (error, uploadedFile) => {
// 更新任务进度
self.updateTask({
id: taskId,
current: successCount + failCount,
currentItem: file.name,
});
if (error) {
failCount++;
} else {
successCount++;
}
checkComplete();
});
}
});
// 检查是否所有文件都上传完成
function checkComplete() {
if (successCount + failCount === pendingFiles.length) {
// 完成任务
self.finishTask({
id: taskId,
total: total,
success: successCount,
failed: failCount,
});
// 触发上传完成回调
if (typeof self.options.onUploadComplete === "function") {
self.options.onUploadComplete(successCount, failCount);
}
}
}
},
// ============================================================================
// Toast 提示系统
// ============================================================================
/**
* Toast 图标映射
*/
toastIcons: {
success: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>`,
error: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>`,
info: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>`,
warning: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 9v4"/><path d="M12 17h.01"/><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg>`,
},
/**
* 显示 Toast 提示
* @param {Object} options - Toast 配置
* @param {string} options.title - 标题
* @param {string} options.message - 消息内容
* @param {string} options.type - 类型:success/error/info/warning
* @param {number} options.duration - 显示时长(毫秒),默认 3000
*/
showToast: function (options) {
const { title, message, type = "info", duration = 3000 } = options;
const self = this;
const toastContainer = this.$container.find(".g3-toast-container");
if (!toastContainer.length) {
this.$container.append('<div class="g3-toast-container"></div>');
this.$toastContainer = this.$container.find(".g3-toast-container");
}
const toastId = "toast_" + this.generateUniqueId();
const toastType = this.language.toast[type] || type;
const icon = this.toastIcons[type] || this.toastIcons.info;
const toastHtml = `
<div class="g3-toast toast-${type}" id="${toastId}">
<div class="toast-icon">${icon}</div>
<div class="toast-content">
<div class="toast-title">${toastType}</div>
${message ? `<div class="toast-message">${message}</div>` : ""}
</div>
<button class="toast-close">&times;</button>
</div>
`;
this.$toastContainer.append(toastHtml);
const $toast = this.$container.find(`#${toastId}`);
$toast.find(".toast-close").off("click").on("click", function () {
self.removeToast(toastId);
});
setTimeout(function () {
self.removeToast(toastId);
}, duration);
const maxToasts = 5;
const toasts = this.$toastContainer.find(".g3-toast");
if (toasts.length > maxToasts) {
toasts.first().remove();
}
},
/**
* 移除 Toast
* @param {string} toastId - Toast ID
*/
removeToast: function (toastId) {
const $toast = this.$container.find(`#${toastId}`);
if ($toast.length) {
$toast.addClass("closing");
setTimeout(function () {
$toast.remove();
}, 300);
}
},
// ============================================================================
// Task Center 任务中心系统
// ============================================================================
/**
* 创建并启动任务
* @param {Object} options - 任务配置
* @param {string} options.id - 任务ID
* @param {string} options.title - 任务标题(国际化key或文本)
* @param {number} options.total - 总数量
* @param {number} options.current - 当前进度
* @param {string} options.currentItem - 当前处理项
*/
startTask: function (options) {
const { id, title, total = 0, current = 0, currentItem = "" } = options;
this.currentTask = {
id: id || this.generateUniqueId(),
title: title,
total: total,
current: current,
currentItem: currentItem,
success: 0,
failed: 0,
status: "running",
};
this.lockUI();
this.renderTaskCenter();
},
/**
* 更新任务进度
* @param {Object} options - 更新配置
* @param {string} options.id - 任务ID
* @param {number} options.current - 当前进度
* @param {string} options.currentItem - 当前处理项
*/
updateTask: function (options) {
const { id, current, currentItem } = options;
if (!this.currentTask || this.currentTask.id !== id) {
return;
}
if (current !== undefined) {
this.currentTask.current = current;
}
if (currentItem !== undefined) {
this.currentTask.currentItem = currentItem;
}
this.updateTaskCenter();
},
/**
* 完成任务
* @param {Object} options - 完成配置
* @param {string} options.id - 任务ID
* @param {number} options.total - 总数量
* @param {number} options.success - 成功数量
* @param {number} options.failed - 失败数量
*/
finishTask: function (options) {
const { id, total, success = 0, failed = 0 } = options;
if (!this.currentTask || this.currentTask.id !== id) {
return;
}
this.currentTask.status = "finished";
this.currentTask.total = total !== undefined ? total : this.currentTask.total;
this.currentTask.success = success;
this.currentTask.failed = failed;
this.currentTask.current = this.currentTask.total;
this.unlockUI();
this.updateTaskCenter();
},
/**
* 手动关闭任务 UI
* @param {string} id - 任务ID
*/
closeTask: function (id) {
if (this.currentTask && this.currentTask.id === id) {
this.$taskCenter.removeClass("is-visible");
this.currentTask = null;
}
},
/**
* 渲染任务中心 UI
*/
renderTaskCenter: function () {
const task = this.currentTask;
if (!task) return;
let taskCenter = this.$container.find(".g3-task-center");
if (!taskCenter.length) {
this.$container.append(`
<div class="g3-task-center">
<div class="task-header">
<span class="task-title">${task.title}</span>
<button class="task-close">&times;</button>
</div>
<div class="task-body">
<div class="current-item"></div>
<div class="progress-bar">
<div class="progress-inner"></div>
</div>
<div class="progress-text"></div>
<div class="result-section" style="display: none;">
<div class="result-item">
<div class="result-value"></div>
<div class="result-label">${this.language.task.total}</div>
</div>
<div class="result-item result-success">
<div class="result-value"></div>
<div class="result-label">${this.language.task.success}</div>
</div>
<div class="result-item result-failed">
<div class="result-value"></div>
<div class="result-label">${this.language.task.failed}</div>
</div>
</div>
<button class="complete-button" style="display: none;">${this.language.task.close}</button>
</div>
</div>
`);
taskCenter = this.$container.find(".g3-task-center");
const self = this;
taskCenter.find(".task-close, .complete-button").off("click").on("click", function () {
self.closeTask(self.currentTask?.id);
});
}
this.$taskCenter = taskCenter;
this.$taskCenter.addClass("is-visible");
this.updateTaskCenter();
},
/**
* 更新任务中心 UI
*/
updateTaskCenter: function () {
const task = this.currentTask;
if (!task || !this.$taskCenter) return;
const progress = task.total > 0 ? ((task.current / task.total) * 100).toFixed(0) : 0;
this.$taskCenter.find(".task-title").text(task.title);
this.$taskCenter.find(".current-item").text(
task.currentItem ? `${this.language.task.currentItem}: ${task.currentItem}` : ""
);
this.$taskCenter.find(".progress-inner").css("width", `${progress}%`);
this.$taskCenter.find(".progress-text").text(`${task.current} / ${task.total}`);
if (task.status === "finished") {
this.$taskCenter.find(".result-section").show();
this.$taskCenter.find(".complete-button").show();
this.$taskCenter.find(".result-item:first .result-value").text(task.total);
this.$taskCenter.find(".result-success .result-value").text(task.success);
this.$taskCenter.find(".result-failed .result-value").text(task.failed);
} else {
this.$taskCenter.find(".result-section").hide();
this.$taskCenter.find(".complete-button").hide();
}
},
// ============================================================================
// UI Lock 机制
// ============================================================================
/**
* 锁定 UI
*/
lockUI: function () {
this.uiLocked = true;
let uiLock = this.$container.find(".g3-ui-lock");
if (!uiLock.length) {
this.$container.append('<div class="g3-ui-lock"></div>');
uiLock = this.$container.find(".g3-ui-lock");
}
this.$uiLock = uiLock;
this.$uiLock.addClass("is-locked");
},
/**
* 解锁 UI
*/
unlockUI: function () {
this.uiLocked = false;
this.$uiLock?.removeClass("is-locked");
},
// ============================================================================
// 操作菜单相关方法
// ============================================================================
/**
* 关闭操作菜单
*/
closeActionMenu: function () {
this.actionMenuState.open = false;
this.actionMenuState.fileId = null;
this.actionMenuState.anchorEl = null;
this.actionMenuState.items = [];
this.renderActionMenu();
},
/**
* 打开操作菜单
* @param {jQuery} $el - 触发元素
* @param {Object} file - 文件对象
*/
openActionMenu: function ($el, file) {
var items =
(this.options.getActionMenu && this.options.getActionMenu(file)) || [];
this.actionMenuState = {
open: true,
fileId: file._id,
anchorEl: $el[0],
items: items,
};
this.renderActionMenu();
},
// ============================================================================
// 权限相关方法
// ============================================================================
/**
* 权限判断(唯一入口)
* @param {Object} file - 文件对象
* @param {string} action - 操作类型
* @returns {boolean} 是否有权限
*/
hasPermission: function (file, action) {
var globalPermission = this.options.globalPermission;
var filePermissionResolver = this.options.filePermissionResolver;
// 1. 优先判断单文件权限解析函数
if (typeof filePermissionResolver === "function") {
var fileResult = filePermissionResolver({ file: file, action: action });
if (fileResult !== undefined) {
return !!fileResult;
}
}
// 2. 判断全局权限
if (typeof globalPermission === "function") {
return !!globalPermission({ file: file, action: action });
} else if (typeof globalPermission === "object") {
return !!globalPermission[action];
}
// 默认返回 true
return true;
},
/**
* 更新权限配置
* @param {Object} config - 权限配置
*/
updatePermissions: function (config) {
if (!config) return;
if (config.globalPermission !== undefined) {
this.options.globalPermission = config.globalPermission;
}
if (config.filePermissionResolver !== undefined) {
this.options.filePermissionResolver = config.filePermissionResolver;
}
this.updateUI();
},
// ============================================================================
// 其他方法
// ============================================================================
/**
* 根据ID获取文件
* @param {string} id - 文件ID
* @returns {Object} 文件对象
*/
getFileById: function (id) {
return this.files.find(function (f) {
return f._id === id;
});
},
/**
* 获取选中文件
* @returns {Array} 选中文件数组
*/
getSelectedFiles: function () {
return this.files.filter((file) => this.selectedFiles.has(file._id));
},
/**
* 获取所有文件
* @returns {Array} 文件数组
*/
getFiles: function () {
return this.files;
},
/**
* 设置文件
* @param {Array} files - 文件数组
* @returns {G3File} 实例
*/
setFiles: function (files) {
var self = this;
this.files = files.map(function (file) {
return {
_id: file._id || self.generateUniqueId(),
name: file.name,
size: file.size,
uploadDate: file.uploadDate || new Date().toISOString(),
url: file.url || "",
file: file.file || null,
status: file.status || "uploaded",
...file,
};
});
this.selectedFiles.clear();
this.renderFileList();
this.updateUI();
return this;
},
/**
* 更新状态
* @param {Object} partialState - 部分状态
* @returns {G3File} 实例
*/
updateState: function (partialState) {
if (partialState.files) {
this.setFiles(partialState.files);
} else {
// 其他状态更新时关闭当前任务
if (this.currentTask) {
this.closeTask(this.currentTask.id);
}
}
if (partialState.selectedFiles) {
this.selectedFiles = new Set(partialState.selectedFiles);
this.updateUI();
}
if (
partialState.globalPermission ||
partialState.filePermissionResolver
) {
this.updatePermissions({
globalPermission: partialState.globalPermission,
filePermissionResolver: partialState.filePermissionResolver,
});
}
return this;
},
/**
* 销毁
*/
destroy: function () {
this.$container.off();
// 清理ObjectURL
this.objectUrls.forEach((url) => {
try {
URL.revokeObjectURL(url);
} catch (e) {
console.warn("释放ObjectURL失败:", e);
}
});
this.objectUrls.clear();
// 清理引用
this.files = [];
this.selectedFiles.clear();
this.$container.empty();
console.log("G3File 组件已销毁");
},
};
// ============================================================================
// jQuery 插件封装
// ============================================================================
$.fn.G3File = function (options) {
var args = Array.prototype.slice.call(arguments, 1);
var instance;
var methodResult; // 存储方法调用结果
this.each(function () {
var $this = $(this);
instance = $this.data("G3File");
if (!instance) {
instance = new G3File(this, options);
$this.data("G3File", instance);
} else if (typeof options === "string") {
var method = options;
if (instance[method]) {
// 保存方法调用结果
methodResult = instance[method].apply(instance, args);
}
}
});
return instance || this;
};
})(jQuery);