// 素材管理主逻辑模块(参考 client/js/disk/disk.js)
class ResourceManager {
constructor(options = {}) {
this.apiBaseUrl = options.apiBaseUrl || 'http://localhost:3000';
this.files = [];
this.currentPath = '';
// DOM 元素
this.container = document.querySelector('.disk-container');
this.dropZone = null;
this.fileList = null;
this.breadcrumb = null;
this.emptyState = null;
this.loading = null;
this.selectionBar = null;
this.selectionCount = null;
this.selectionBox = null;
this.contextMenu = null;
this.fileInput = null;
this.searchInput = null;
this.searchClear = null;
this.btnUpload = null;
this.btnDelete = null;
// 功能模块
this.pathNav = null;
this.selection = null;
this.searchBar = null;
this.contextMenuManager = null;
this.shortcutKeys = null;
// 等待 DOM 加载完成
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => this.init());
} else {
this.init();
}
}
async init() {
this.initElements();
this.initPath();
this.initSelection();
this.initShortcutKeys();
this.initSearchBar();
this.initContextMenu();
this.bindEvents();
// 加载文件列表
await this.loadFiles();
}
initElements() {
this.dropZone = document.getElementById('dropZone');
this.fileList = document.getElementById('fileList');
this.breadcrumb = document.getElementById('breadcrumb');
this.emptyState = document.getElementById('emptyState');
this.loading = document.getElementById('loading');
this.selectionBar = document.getElementById('selectionBar');
this.selectionCount = document.getElementById('selectionCount');
this.selectionBox = document.getElementById('selectionBox');
this.contextMenu = document.getElementById('contextMenu');
this.fileInput = null; // 动态创建
this.searchInput = document.getElementById('searchInput');
this.searchClear = document.getElementById('searchClear');
this.btnUpload = document.getElementById('btnUpload');
this.btnDelete = document.getElementById('btnDelete');
}
// 初始化路径导航
initPath() {
if (this.breadcrumb) {
this.pathNav = new ResourceManagerPathNavigator({
container: this.breadcrumb,
rootName: '全部文件',
onNavigate: (path) => {
this.loadFiles(path);
}
});
}
}
// 初始化框选功能
initSelection() {
if (this.dropZone && this.fileList && this.selectionBox) {
this.selection = new ResourceManagerMultipleSelection({
container: this.dropZone,
itemsContainer: this.fileList,
selectionBox: this.selectionBox,
selectionBar: this.selectionBar,
selectionCount: this.selectionCount,
itemSelector: '.file-item',
onSelectionChange: (selectedItems) => {
// 选择变化时的回调
}
});
}
}
// 初始化快捷键
initShortcutKeys() {
if (this.shortcutKeys) {
this.shortcutKeys.destroy();
}
this.shortcutKeys = new ResourceManagerShortcutKeys({
selection: this.selection,
onDelete: () => this.deleteSelected(),
onRename: () => this.renameSelected()
});
}
// 初始化搜索栏
initSearchBar() {
if (this.searchInput && this.fileList) {
this.searchBar = new ResourceManagerSearchBar({
input: this.searchInput,
clearButton: this.searchClear,
fileList: this.fileList,
emptyState: this.emptyState,
getResources: () => this.files,
renderAll: () => this.renderFiles(),
createFileItem: (file) => this.createFileItem(file),
noResultMessage: '没有找到匹配的资源'
});
}
}
// 初始化右键菜单
initContextMenu() {
if (!this.dropZone || !this.contextMenu) return;
this.contextMenuManager = new ResourceManagerRightClickMenu({
target: this.dropZone,
menu: this.contextMenu,
onAction: (action, event) => this.handleContextMenuAction(action, event),
onBeforeShow: (event) => this.handleBeforeShowContextMenu(event)
});
}
// 显示右键菜单前的处理
handleBeforeShowContextMenu(event) {
const fileItem = event.target.closest('.file-item');
if (!this.contextMenu) return false;
const newBtn = this.contextMenu.querySelector('[data-action="new"]');
const uploadBtn = this.contextMenu.querySelector('[data-action="upload"]');
const backBtn = this.contextMenu.querySelector('[data-action="back"]');
const deleteBtn = this.contextMenu.querySelector('[data-action="delete"]');
const renameBtn = this.contextMenu.querySelector('[data-action="rename"]');
const refreshBtn = this.contextMenu.querySelector('[data-action="refresh"]');
const isRoot = this.currentPath === '';
// 新建分类:只在根目录时显示
if (newBtn) {
newBtn.style.display = isRoot ? 'flex' : 'none';
}
// 上传素材:只在子目录时显示
if (uploadBtn) {
uploadBtn.style.display = isRoot ? 'none' : 'flex';
}
// 返回上级:只在子目录时显示
if (backBtn) {
backBtn.style.display = isRoot ? 'none' : 'flex';
}
// 刷新按钮始终显示
if (refreshBtn) {
refreshBtn.style.display = 'flex';
}
// 如果点击在文件项上
if (fileItem) {
// 确保被点击的项被选中
const isSelected = this.selection && this.selection.isSelected(fileItem.dataset.path);
if (!isSelected && this.selection) {
this.selection.selectOnly(fileItem);
}
if (deleteBtn) deleteBtn.style.display = 'flex';
if (renameBtn) renameBtn.style.display = 'flex';
} else {
if (deleteBtn) deleteBtn.style.display = 'none';
if (renameBtn) renameBtn.style.display = 'none';
}
// 更新分隔线显示
this.updateContextMenuDividers();
return true;
}
// 更新右键菜单分隔线显示
updateContextMenuDividers() {
if (!this.contextMenu) return;
const items = Array.from(this.contextMenu.children);
let lastVisibleWasDivider = true;
items.forEach((item, index) => {
if (item.classList.contains('context-menu-divider')) {
// 如果上一个可见项也是分隔线,或者是第一个,隐藏
if (lastVisibleWasDivider) {
item.style.display = 'none';
} else {
item.style.display = 'block';
lastVisibleWasDivider = true;
}
} else if (item.style.display !== 'none') {
lastVisibleWasDivider = false;
}
});
// 隐藏末尾的分隔线
for (let i = items.length - 1; i >= 0; i--) {
const item = items[i];
if (item.classList.contains('context-menu-divider')) {
if (item.style.display !== 'none') {
item.style.display = 'none';
}
} else if (item.style.display !== 'none') {
break;
}
}
}
// 处理右键菜单操作
handleContextMenuAction(action) {
switch (action) {
case 'new':
this.createFolder();
break;
case 'upload':
// 触发上传
this.triggerUpload();
break;
case 'back':
// 返回上级目录
this.loadFiles('');
break;
case 'delete':
this.deleteSelected();
break;
case 'rename':
this.renameSelected();
break;
case 'refresh':
this.loadFiles(this.currentPath);
break;
}
}
bindEvents() {
// 上传按钮已移除,使用右键菜单或拖拽上传
// 删除按钮
if (this.btnDelete) {
this.btnDelete.addEventListener('click', () => this.deleteSelected());
}
// 拖拽上传
if (this.dropZone) {
console.log('[ResourceManager] 绑定拖拽事件到 dropZone:', this.dropZone);
this.dropZone.addEventListener('dragenter', (e) => this.handleDragEnter(e));
this.dropZone.addEventListener('dragover', (e) => this.handleDragOver(e));
this.dropZone.addEventListener('dragleave', (e) => this.handleDragLeave(e));
this.dropZone.addEventListener('drop', (e) => this.handleDrop(e));
} else {
console.error('[ResourceManager] dropZone 未找到!');
}
}
// 触发上传(动态创建文件输入)
triggerUpload() {
// 创建新的文件输入(每次都重新创建以确保属性正确)
const newInput = document.createElement('input');
newInput.type = 'file';
newInput.style.display = 'none';
// 使用文件夹选择模式
newInput.webkitdirectory = true;
newInput.directory = true;
newInput.multiple = true;
// 移除旧的文件输入
if (this.fileInput) {
this.fileInput.remove();
}
// 添加到DOM
document.body.appendChild(newInput);
this.fileInput = newInput;
// 绑定change事件 - 跳过浏览器确认后直接处理
this.fileInput.addEventListener('change', (e) => this.handleFileSelectDirect(e));
// 触发点击
this.fileInput.click();
}
// 直接处理文件选择(跳过额外确认)
async handleFileSelectDirect(e) {
const files = Array.from(e.target.files);
if (files.length === 0) {
this.fileInput.value = '';
return;
}
// 验证文件夹结构
const validation = this.validateFolderFiles(files);
if (!validation.valid) {
this.showError(validation.errors.join('\n'));
this.fileInput.value = '';
return;
}
// 浏览器已经确认过了,直接上传,不再弹出我们的确认框
const filesToUpload = validation.files.map(file => ({
file: file,
path: file.webkitRelativePath || file.name
}));
try {
await this.uploadFolderFiles(validation.folderName, filesToUpload);
this.showSuccess(`上传成功: ${validation.folderName}`);
await this.loadFiles(this.currentPath);
} catch (error) {
this.showError('上传失败: ' + error.message);
}
this.fileInput.value = '';
}
// 加载文件列表
async loadFiles(path = '') {
this.showLoading(true);
if (this.selection) {
this.selection.clearSelection();
}
if (this.searchBar) {
this.searchBar.clear({ noRender: true });
}
this.currentPath = path;
try {
if (path === '') {
// 根目录:加载分类列表
const response = await fetch(`${this.apiBaseUrl}/api/store/categories`);
if (!response.ok) {
throw new Error(`加载分类失败: ${response.status}`);
}
const result = await response.json();
if (result.success && result.categories) {
this.files = result.categories.map(cat => ({
name: cat.name,
path: cat.dir,
type: 'directory',
isCategory: true
}));
} else {
this.files = [];
}
} else {
// 子目录:加载资源列表
const response = await fetch(`${this.apiBaseUrl}/api/store/resources?category=${encodeURIComponent(path)}`);
if (!response.ok) {
throw new Error(`加载资源失败: ${response.status}`);
}
const result = await response.json();
if (result.success && result.resources) {
this.files = result.resources.map(res => ({
...res,
type: 'directory'
}));
} else {
this.files = [];
}
}
this.renderFiles();
} catch (error) {
console.error('[ResourceManager] 加载失败:', error);
this.files = [];
this.renderFiles();
this.showError('加载失败: ' + error.message);
} finally {
this.showLoading(false);
}
}
// 渲染文件列表
renderFiles() {
if (!this.fileList) return;
this.fileList.innerHTML = '';
if (this.files.length === 0) {
if (this.emptyState) {
this.emptyState.classList.add('show');
}
return;
}
if (this.emptyState) {
this.emptyState.classList.remove('show');
}
this.files.forEach(file => {
const fileItem = this.createFileItem(file);
this.fileList.appendChild(fileItem);
});
}
// 创建文件项
createFileItem(file) {
const div = document.createElement('div');
div.className = 'file-item';
div.dataset.name = file.name;
div.dataset.type = file.type;
div.dataset.path = file.path;
// 在根目录时,分类文件夹可以拖拽排序
if (this.currentPath === '' && file.isCategory) {
div.draggable = true;
this.setupDragReorder(div, file);
}
// 勾选标记
const checkMark = `
`;
// 检查是否有预览图
if (file.previewUrl) {
div.innerHTML = `
${checkMark}
📁
${file.name}
`;
} else {
div.innerHTML = `
${checkMark}
${file.name}
`;
}
// 点击事件
let clickTimer = null;
div.addEventListener('click', (e) => {
if (e.target.classList.contains('rename-input')) return;
e.stopPropagation();
const isAlreadySelected = this.selection && this.selection.isSelected(file.path);
const clickedOnName = e.target.classList.contains('file-name');
const ctrlKey = e.ctrlKey;
if (clickTimer) {
clearTimeout(clickTimer);
clickTimer = null;
}
clickTimer = setTimeout(() => {
clickTimer = null;
if (ctrlKey && this.selection) {
this.selection.toggleSelection(div);
} else if (clickedOnName && isAlreadySelected) {
this.startRename(div);
} else if (this.selection) {
this.selection.selectOnly(div);
}
}, 200);
});
// 双击进入文件夹
div.addEventListener('dblclick', (e) => {
if (e.target.classList.contains('rename-input')) return;
e.stopPropagation();
if (clickTimer) {
clearTimeout(clickTimer);
clickTimer = null;
}
if (file.type === 'directory') {
if (file.isCategory) {
this.pathNav.navigateTo(file.path);
} else {
// 资源文件夹可以进一步打开查看
// 或者在这里可以打开预览
}
}
});
return div;
}
// 开始重命名
startRename(fileItem) {
const nameEl = fileItem.querySelector('.file-name');
const input = fileItem.querySelector('.rename-input');
if (!nameEl || !input) return;
const oldName = fileItem.dataset.name;
const filePath = fileItem.dataset.path;
input.value = oldName;
nameEl.style.display = 'none';
input.style.display = '';
setTimeout(() => {
const dotIndex = oldName.lastIndexOf('.');
if (dotIndex > 0 && fileItem.dataset.type !== 'directory') {
input.setSelectionRange(0, dotIndex);
} else {
input.select();
}
input.focus();
}, 10);
let finished = false;
const exitRename = () => {
input.style.display = 'none';
nameEl.style.display = '';
};
const commitRename = async () => {
if (finished) return;
finished = true;
const newName = input.value.trim();
exitRename();
if (newName && newName !== oldName) {
await this.renameFile(filePath, newName);
}
};
const cancelRename = () => {
if (finished) return;
finished = true;
exitRename();
};
const handleBlur = () => {
input.removeEventListener('blur', handleBlur);
input.removeEventListener('keydown', handleKeydown);
if (!finished) {
commitRename();
}
};
const handleKeydown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
input.removeEventListener('blur', handleBlur);
input.removeEventListener('keydown', handleKeydown);
commitRename();
} else if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
input.removeEventListener('blur', handleBlur);
input.removeEventListener('keydown', handleKeydown);
cancelRename();
}
};
input.addEventListener('blur', handleBlur);
input.addEventListener('keydown', handleKeydown);
}
// 重命名文件
async renameFile(oldPath, newName) {
try {
const response = await fetch(`${this.apiBaseUrl}/api/admin/store/rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ resourcePath: oldPath, newName })
});
const result = await response.json();
if (result.success) {
this.showSuccess('重命名成功');
await this.loadFiles(this.currentPath);
} else {
this.showError('重命名失败: ' + (result.message || '未知错误'));
await this.loadFiles(this.currentPath);
}
} catch (error) {
this.showError('重命名失败: ' + error.message);
await this.loadFiles(this.currentPath);
}
}
// 重命名选中项
renameSelected() {
if (!this.selection || this.selection.getSelectedCount() !== 1) return;
const selectedPath = this.selection.getSelectedItems()[0];
const selectedItem = this.fileList.querySelector(`[data-path="${selectedPath}"]`);
if (selectedItem) {
this.startRename(selectedItem);
}
}
// 删除选中项
async deleteSelected() {
console.log('[ResourceManager] deleteSelected 被调用');
console.log('[ResourceManager] this.selection:', this.selection);
if (!this.selection) {
console.log('[ResourceManager] selection 为空,退出');
return;
}
const count = this.selection.getSelectedCount();
console.log('[ResourceManager] 选中数量:', count);
if (count === 0) {
console.log('[ResourceManager] 没有选中项,退出');
return;
}
const confirmed = await this.showConfirm(`确定要删除选中的 ${count} 个文件/文件夹吗?`);
if (!confirmed) return;
try {
const selectedPaths = this.selection.getSelectedItems();
for (const path of selectedPaths) {
const response = await fetch(`${this.apiBaseUrl}/api/admin/store/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ resourcePath: path })
});
const result = await response.json();
if (!result.success) {
this.showError('删除失败: ' + (result.message || '未知错误'));
}
}
this.selection.clearSelection();
await this.loadFiles(this.currentPath);
this.showSuccess('删除成功');
} catch (error) {
this.showError('删除失败: ' + error.message);
}
}
// 设置拖拽排序
setupDragReorder(div, file) {
div.addEventListener('dragstart', (e) => {
e.stopPropagation();
div.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', JSON.stringify({
type: 'reorder',
name: file.name
}));
});
div.addEventListener('dragend', () => {
div.classList.remove('dragging');
// 移除所有拖拽目标样式
this.fileList.querySelectorAll('.file-item.drag-over-left, .file-item.drag-over-right').forEach(el => {
el.classList.remove('drag-over-left', 'drag-over-right');
});
// 确保移除上传提示
if (this.dropZone) {
this.dropZone.classList.remove('drag-over');
}
});
div.addEventListener('dragover', (e) => {
e.preventDefault();
// 只有内部拖拽排序时才处理
const draggingEl = this.fileList.querySelector('.file-item.dragging');
if (!draggingEl || draggingEl === div) {
// 外部文件拖入时,不阻止冒泡,让 dropZone 处理
return;
}
e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
// 判断鼠标在元素的左半边还是右半边
const rect = div.getBoundingClientRect();
const midX = rect.left + rect.width / 2;
div.classList.remove('drag-over-left', 'drag-over-right');
if (e.clientX < midX) {
div.classList.add('drag-over-left');
} else {
div.classList.add('drag-over-right');
}
});
div.addEventListener('dragleave', (e) => {
// 只有内部拖拽排序时才阻止冒泡
const draggingEl = this.fileList.querySelector('.file-item.dragging');
if (draggingEl) {
e.stopPropagation();
}
div.classList.remove('drag-over-left', 'drag-over-right');
});
div.addEventListener('drop', async (e) => {
e.preventDefault();
// 如果是外部文件拖入,让事件继续冒泡到 dropZone
if (e.dataTransfer.types.includes('Files') && !e.dataTransfer.getData('text/plain')) {
console.log('[ResourceManager] 外部文件拖入到文件项,转发到 dropZone');
this.handleDrop(e);
return;
}
e.stopPropagation();
const isLeft = div.classList.contains('drag-over-left');
div.classList.remove('drag-over-left', 'drag-over-right');
try {
const data = JSON.parse(e.dataTransfer.getData('text/plain'));
if (data.type !== 'reorder') return;
const draggedName = data.name;
const targetName = file.name;
if (draggedName === targetName) return;
// 重新计算顺序
const currentOrder = this.files.map(f => f.name);
const draggedIndex = currentOrder.indexOf(draggedName);
let targetIndex = currentOrder.indexOf(targetName);
if (draggedIndex === -1 || targetIndex === -1) return;
// 移除拖拽的元素
currentOrder.splice(draggedIndex, 1);
// 重新计算目标位置(因为移除了一个元素)
targetIndex = currentOrder.indexOf(targetName);
// 插入到目标位置
if (isLeft) {
currentOrder.splice(targetIndex, 0, draggedName);
} else {
currentOrder.splice(targetIndex + 1, 0, draggedName);
}
// 保存新顺序
await this.saveCategoryOrder(currentOrder);
} catch (error) {
console.error('[ResourceManager] 拖拽排序失败:', error);
}
});
}
// 保存分类排序
async saveCategoryOrder(order) {
try {
const response = await fetch(`${this.apiBaseUrl}/api/admin/store/update-order`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order })
});
const result = await response.json();
if (result.success) {
this.showSuccess('排序已保存');
await this.loadFiles('');
} else {
this.showError('保存排序失败: ' + (result.message || '未知错误'));
}
} catch (error) {
this.showError('保存排序失败: ' + error.message);
}
}
// 创建文件夹
async createFolder() {
if (this.currentPath !== '') {
this.showError('只能在根目录创建分类文件夹');
return;
}
const folderName = await this.showPrompt('请输入分类文件夹名称:');
if (!folderName || !folderName.trim()) return;
const name = folderName.trim();
if (/[\\/:*?"<>|]/.test(name)) {
this.showError('文件夹名称包含非法字符');
return;
}
try {
const response = await fetch(`${this.apiBaseUrl}/api/admin/store/create-folder`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
const result = await response.json();
if (result.success) {
this.showSuccess('创建文件夹成功');
await this.loadFiles('');
} else {
this.showError('创建文件夹失败: ' + (result.message || '未知错误'));
}
} catch (error) {
this.showError('创建文件夹失败: ' + error.message);
}
}
// 处理文件选择(始终为文件夹模式)
async handleFileSelect(e) {
const files = Array.from(e.target.files);
if (files.length === 0) {
this.fileInput.value = '';
return;
}
// 验证文件夹结构
const validation = this.validateFolderFiles(files);
if (!validation.valid) {
this.showError(validation.errors.join('\n'));
this.fileInput.value = '';
return;
}
const targetPath = this.currentPath || validation.folderName;
const confirmMsg = this.currentPath
? `将上传文件夹 "${validation.folderName}" 到分类 "${this.currentPath}",包含 ${validation.files.length} 个PNG图片,确认上传?`
: `将上传文件夹 "${validation.folderName}",包含 ${validation.files.length} 个PNG图片,确认上传?`;
const confirmed = await this.showConfirm(confirmMsg);
if (!confirmed) {
this.fileInput.value = '';
return;
}
// 根据当前位置决定上传方式
// 转换 validation.files (File[]) 为 {file, path}[] 格式
const filesToUpload = validation.files.map(file => ({
file: file,
path: file.webkitRelativePath || file.name
}));
if (this.currentPath === '') {
// 在根目录上传,文件夹名就是分类名
await this.uploadFolderFiles(validation.folderName, filesToUpload);
} else {
// 在子目录上传
await this.uploadFolderFiles(validation.folderName, filesToUpload);
}
this.fileInput.value = '';
}
// 验证通过文件输入选择的文件夹
validateFolderFiles(files) {
const errors = [];
const validFiles = [];
let folderName = '';
// 获取文件夹名(第一层目录)
const paths = new Set();
for (const file of files) {
const relativePath = file.webkitRelativePath || file.name;
const parts = relativePath.split('/');
if (parts.length > 0) {
paths.add(parts[0]);
if (!folderName) folderName = parts[0];
}
}
// 检查是否只有一个顶级文件夹
if (paths.size > 1) {
errors.push('请只选择一个文件夹');
return { valid: false, errors, files: [], folderName: '' };
}
for (const file of files) {
const relativePath = file.webkitRelativePath || file.name;
const parts = relativePath.split('/');
// 检查是否有子文件夹(路径深度超过2层)
if (parts.length > 2) {
errors.push(`不允许有子文件夹: ${parts.slice(0, -1).join('/')}`);
continue;
}
// 检查文件类型
const fileName = file.name.toLowerCase();
if (!fileName.endsWith('.png')) {
errors.push(`文件 "${file.name}" 不是PNG格式,只允许PNG图片`);
continue;
}
validFiles.push(file);
}
if (validFiles.length === 0 && errors.length === 0) {
errors.push('文件夹为空或没有PNG图片');
}
// 只显示前5个错误
const displayErrors = errors.slice(0, 5);
if (errors.length > 5) {
displayErrors.push(`... 还有 ${errors.length - 5} 个错误`);
}
return {
valid: errors.length === 0,
errors: displayErrors,
files: validFiles,
folderName
};
}
// 上传文件
async uploadFiles(files) {
if (files.length === 0) return;
// 获取目标路径
const category = this.currentPath || files[0].webkitRelativePath?.split('/')[0] || 'default';
const folderName = files[0].webkitRelativePath?.split('/')[0] || 'upload_' + Date.now();
const formData = new FormData();
formData.append('category', category);
formData.append('name', folderName);
formData.append('price', 0);
for (const file of files) {
formData.append('files', file);
}
try {
const response = await fetch(`${this.apiBaseUrl}/api/admin/store/upload`, {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
this.showSuccess('上传成功');
await this.loadFiles(this.currentPath);
} else {
this.showError('上传失败: ' + (result.message || '未知错误'));
}
} catch (error) {
this.showError('上传失败: ' + error.message);
}
}
// 检查是否是外部文件拖入(而非内部排序拖拽)
isExternalFileDrag(e) {
// 如果有正在拖拽的内部元素,说明是内部排序
if (this.fileList && this.fileList.querySelector('.file-item.dragging')) {
return false;
}
// 检查是否有文件类型
return e.dataTransfer.types.includes('Files');
}
// 拖拽处理
handleDragEnter(e) {
e.preventDefault();
e.stopPropagation();
console.log('[ResourceManager] dragenter 触发');
// 只有外部文件拖入时才显示上传提示
if (this.isExternalFileDrag(e)) {
this.dropZone.classList.add('drag-over');
}
}
handleDragOver(e) {
e.preventDefault();
e.stopPropagation();
}
handleDragLeave(e) {
e.preventDefault();
e.stopPropagation();
// 检查是否真正离开了 dropZone(而不是进入子元素)
if (!this.dropZone.contains(e.relatedTarget)) {
this.dropZone.classList.remove('drag-over');
}
}
async handleDrop(e) {
console.log('[ResourceManager] ===== handleDrop 触发 =====');
e.preventDefault();
e.stopPropagation();
this.dropZone.classList.remove('drag-over');
console.log('[ResourceManager] dataTransfer.types:', Array.from(e.dataTransfer.types));
console.log('[ResourceManager] isExternalFileDrag:', this.isExternalFileDrag(e));
// 如果是内部排序拖拽,不处理文件上传
if (!this.isExternalFileDrag(e)) {
console.log('[ResourceManager] 是内部拖拽,跳过');
return;
}
const items = e.dataTransfer.items;
console.log('[ResourceManager] items:', items, 'length:', items ? items.length : 0);
if (!items) return;
const entries = [];
for (let i = 0; i < items.length; i++) {
const item = items[i].webkitGetAsEntry();
console.log(`[ResourceManager] entry ${i}:`, item ? item.name : 'null');
if (item) {
entries.push(item);
}
}
console.log('[ResourceManager] 共收集到', entries.length, '个 entries');
await this.processDropEntries(entries);
}
// 处理拖入的 entries(完全按照 client/js/disk/disk.js 的方式)
async processDropEntries(entries) {
console.log('[ResourceManager] processDropEntries 开始处理', entries.length, '个 entries');
// 检查是否有非文件夹
const hasFiles = entries.some(entry => entry.isFile);
if (hasFiles) {
this.showError('只能拖拽文件夹,不能拖拽单个文件');
return;
}
const filesToUpload = [];
for (const entry of entries) {
console.log('[ResourceManager] 开始遍历:', entry.name);
try {
await this.traverseEntry(entry, '', filesToUpload);
console.log('[ResourceManager] 遍历完成:', entry.name, '当前文件数:', filesToUpload.length);
} catch (err) {
console.error('[ResourceManager] 遍历失败:', entry.name, err);
}
}
console.log('[ResourceManager] 总共收集到', filesToUpload.length, '个 PNG 文件');
if (filesToUpload.length > 0) {
// 按文件夹分组
const folderMap = new Map();
for (const item of filesToUpload) {
const folderName = item.path.split('/')[0];
if (!folderMap.has(folderName)) {
folderMap.set(folderName, []);
}
folderMap.get(folderName).push(item);
}
const folderNames = Array.from(folderMap.keys());
const confirmMsg = `将上传 ${folderMap.size} 个文件夹(${folderNames.slice(0, 5).join('、')}${folderNames.length > 5 ? '...' : ''}),共 ${filesToUpload.length} 个PNG图片\n\n确认上传?`;
const confirmed = await this.showConfirm(confirmMsg);
if (!confirmed) {
return;
}
// 按文件夹逐个上传,每上传完一个就刷新显示
let successCount = 0;
let failCount = 0;
const totalCount = folderMap.size;
for (const [folderName, files] of folderMap) {
try {
await this.uploadFolderFiles(folderName, files);
successCount++;
// 每上传完一个文件夹就刷新显示
await this.loadFiles(this.currentPath);
this.showInfo(`上传进度: ${successCount}/${totalCount} - ${folderName}`);
} catch (error) {
console.error(`[ResourceManager] 上传 ${folderName} 失败:`, error);
failCount++;
}
}
if (failCount === 0) {
this.showSuccess(`成功上传 ${successCount} 个文件夹`);
} else {
this.showWarning(`上传完成: ${successCount} 成功, ${failCount} 失败`);
}
} else {
console.log('[ResourceManager] 没有找到可上传的PNG图片');
this.showError('没有找到可上传的PNG图片');
}
}
// 递归遍历文件夹
async traverseEntry(entry, relativePath, filesToUpload) {
console.log('[ResourceManager] traverseEntry:', entry.name, 'isFile:', entry.isFile, 'isDirectory:', entry.isDirectory);
if (entry.isFile) {
console.log('[ResourceManager] 读取文件:', entry.name);
const file = await new Promise((resolve, reject) => {
entry.file(resolve, reject);
});
console.log('[ResourceManager] 文件读取完成:', file.name, file.size);
// 只接受 PNG 文件
if (file.name.toLowerCase().endsWith('.png')) {
filesToUpload.push({
file: file,
path: relativePath + file.name
});
console.log('[ResourceManager] 添加PNG:', relativePath + file.name);
}
} else if (entry.isDirectory) {
console.log('[ResourceManager] 开始读取目录:', entry.name);
const dirReader = entry.createReader();
// 读取所有条目 - readEntries 每次最多返回100条,需要循环读取
let allEntries = [];
let batch;
let readCount = 0;
do {
console.log('[ResourceManager] 调用 readEntries 第', readCount + 1, '次');
batch = await new Promise((resolve) => {
dirReader.readEntries(
(entries) => {
console.log('[ResourceManager] readEntries 成功, 返回', entries.length, '条');
resolve(entries);
},
(err) => {
console.error('[ResourceManager] readEntries 错误:', err);
resolve([]); // 出错返回空数组继续
}
);
});
allEntries = allEntries.concat(batch);
readCount++;
} while (batch.length > 0);
console.log('[ResourceManager] 目录', entry.name, '共有', allEntries.length, '个条目');
for (const childEntry of allEntries) {
await this.traverseEntry(
childEntry,
relativePath + entry.name + '/',
filesToUpload
);
}
console.log('[ResourceManager] 目录', entry.name, '遍历完成');
}
}
// 上传单个文件夹的文件
async uploadFolderFiles(folderName, files) {
if (files.length === 0) return;
const category = this.currentPath || folderName;
const formData = new FormData();
formData.append('category', category);
formData.append('name', folderName);
formData.append('price', '0');
for (const item of files) {
formData.append('files', item.file, item.path);
}
const response = await fetch(`${this.apiBaseUrl}/api/admin/store/upload`, {
method: 'POST',
body: formData
});
const result = await response.json();
if (!result.success) {
throw new Error(result.message || '上传失败');
}
return result;
}
isImageFile(fileName) {
const ext = fileName.split('.').pop().toLowerCase();
return ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(ext);
}
showLoading(show) {
if (this.loading) {
if (show) {
this.loading.classList.add('show');
} else {
this.loading.classList.remove('show');
}
}
}
// 显示提示消息
showHint(message, type = 'info') {
const hintView = document.getElementById('hintView');
const hintMessage = document.getElementById('hintMessage');
if (!hintView || !hintMessage) {
console.log(`[${type}]`, message);
return;
}
// 移除之前的类型类
hintView.classList.remove('success', 'error', 'warning', 'info');
hintView.classList.add(type);
hintMessage.textContent = message;
hintView.classList.remove('hide');
hintView.classList.add('show');
// 自动隐藏
setTimeout(() => {
hintView.classList.remove('show');
hintView.classList.add('hide');
}, 3000);
}
showError(message) {
this.showHint(message, 'error');
}
showSuccess(message) {
this.showHint(message, 'success');
}
showWarning(message) {
this.showHint(message, 'warning');
}
showInfo(message) {
this.showHint(message, 'info');
}
// 显示确认对话框
showConfirm(message) {
return new Promise((resolve) => {
const overlay = document.getElementById('globalConfirmOverlay');
const messageEl = document.getElementById('confirmMessage');
const okBtn = document.getElementById('confirmOkBtn');
const cancelBtn = document.getElementById('confirmCancelBtn');
if (!overlay || !messageEl || !okBtn || !cancelBtn) {
// 降级到原生 confirm
resolve(confirm(message));
return;
}
messageEl.textContent = message;
overlay.classList.add('show');
const cleanup = () => {
overlay.classList.remove('show');
okBtn.removeEventListener('click', handleOk);
cancelBtn.removeEventListener('click', handleCancel);
};
const handleOk = () => {
cleanup();
resolve(true);
};
const handleCancel = () => {
cleanup();
resolve(false);
};
okBtn.addEventListener('click', handleOk);
cancelBtn.addEventListener('click', handleCancel);
});
}
// 显示输入对话框
showPrompt(message, defaultValue = '') {
return new Promise((resolve) => {
const overlay = document.getElementById('globalPromptOverlay');
const messageEl = document.getElementById('promptMessage');
const input = document.getElementById('promptInput');
const okBtn = document.getElementById('promptOkBtn');
const cancelBtn = document.getElementById('promptCancelBtn');
if (!overlay || !messageEl || !input || !okBtn || !cancelBtn) {
// 降级到原生 prompt
resolve(prompt(message, defaultValue));
return;
}
messageEl.textContent = message;
input.value = defaultValue;
overlay.classList.add('show');
// 自动聚焦输入框
setTimeout(() => {
input.focus();
input.select();
}, 100);
const cleanup = () => {
overlay.classList.remove('show');
okBtn.removeEventListener('click', handleOk);
cancelBtn.removeEventListener('click', handleCancel);
input.removeEventListener('keydown', handleKeydown);
};
const handleOk = () => {
const value = input.value;
cleanup();
resolve(value);
};
const handleCancel = () => {
cleanup();
resolve(null);
};
const handleKeydown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleOk();
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancel();
}
};
okBtn.addEventListener('click', handleOk);
cancelBtn.addEventListener('click', handleCancel);
input.addEventListener('keydown', handleKeydown);
});
}
}
// 导出
if (typeof module !== 'undefined' && module.exports) {
module.exports = ResourceManager;
} else {
window.ResourceManager = ResourceManager;
}