|
|
@@ -0,0 +1,1373 @@
|
|
|
+// 素材管理主逻辑模块(参考 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 = `
|
|
|
+ <div class="check-mark">
|
|
|
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
|
|
+ <path d="M10 3L4.5 8.5 2 6" stroke="white" stroke-width="2" fill="none"/>
|
|
|
+ </svg>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+
|
|
|
+ // 检查是否有预览图
|
|
|
+ if (file.previewUrl) {
|
|
|
+ div.innerHTML = `
|
|
|
+ ${checkMark}
|
|
|
+ <div class="file-thumbnail folder-preview">
|
|
|
+ <img src="${this.apiBaseUrl}${file.previewUrl}" alt="${file.name}" loading="lazy">
|
|
|
+ <div class="folder-badge">📁</div>
|
|
|
+ </div>
|
|
|
+ <div class="file-name">${file.name}</div>
|
|
|
+ <input type="text" class="rename-input" style="display: none;">
|
|
|
+ `;
|
|
|
+ } else {
|
|
|
+ div.innerHTML = `
|
|
|
+ ${checkMark}
|
|
|
+ <div class="file-icon folder-icon">
|
|
|
+ <svg viewBox="0 0 64 64" fill="currentColor">
|
|
|
+ <path d="M8 12h20l4 6h24a4 4 0 014 4v28a4 4 0 01-4 4H8a4 4 0 01-4-4V16a4 4 0 014-4z"/>
|
|
|
+ </svg>
|
|
|
+ </div>
|
|
|
+ <div class="file-name">${file.name}</div>
|
|
|
+ <input type="text" class="rename-input" style="display: none;">
|
|
|
+ `;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 点击事件
|
|
|
+ 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;
|
|
|
+}
|