resource-manager.js 47 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373
  1. // 素材管理主逻辑模块(参考 client/js/disk/disk.js)
  2. class ResourceManager {
  3. constructor(options = {}) {
  4. this.apiBaseUrl = options.apiBaseUrl || 'http://localhost:3000';
  5. this.files = [];
  6. this.currentPath = '';
  7. // DOM 元素
  8. this.container = document.querySelector('.disk-container');
  9. this.dropZone = null;
  10. this.fileList = null;
  11. this.breadcrumb = null;
  12. this.emptyState = null;
  13. this.loading = null;
  14. this.selectionBar = null;
  15. this.selectionCount = null;
  16. this.selectionBox = null;
  17. this.contextMenu = null;
  18. this.fileInput = null;
  19. this.searchInput = null;
  20. this.searchClear = null;
  21. this.btnUpload = null;
  22. this.btnDelete = null;
  23. // 功能模块
  24. this.pathNav = null;
  25. this.selection = null;
  26. this.searchBar = null;
  27. this.contextMenuManager = null;
  28. this.shortcutKeys = null;
  29. // 等待 DOM 加载完成
  30. if (document.readyState === 'loading') {
  31. document.addEventListener('DOMContentLoaded', () => this.init());
  32. } else {
  33. this.init();
  34. }
  35. }
  36. async init() {
  37. this.initElements();
  38. this.initPath();
  39. this.initSelection();
  40. this.initShortcutKeys();
  41. this.initSearchBar();
  42. this.initContextMenu();
  43. this.bindEvents();
  44. // 加载文件列表
  45. await this.loadFiles();
  46. }
  47. initElements() {
  48. this.dropZone = document.getElementById('dropZone');
  49. this.fileList = document.getElementById('fileList');
  50. this.breadcrumb = document.getElementById('breadcrumb');
  51. this.emptyState = document.getElementById('emptyState');
  52. this.loading = document.getElementById('loading');
  53. this.selectionBar = document.getElementById('selectionBar');
  54. this.selectionCount = document.getElementById('selectionCount');
  55. this.selectionBox = document.getElementById('selectionBox');
  56. this.contextMenu = document.getElementById('contextMenu');
  57. this.fileInput = null; // 动态创建
  58. this.searchInput = document.getElementById('searchInput');
  59. this.searchClear = document.getElementById('searchClear');
  60. this.btnUpload = document.getElementById('btnUpload');
  61. this.btnDelete = document.getElementById('btnDelete');
  62. }
  63. // 初始化路径导航
  64. initPath() {
  65. if (this.breadcrumb) {
  66. this.pathNav = new ResourceManagerPathNavigator({
  67. container: this.breadcrumb,
  68. rootName: '全部文件',
  69. onNavigate: (path) => {
  70. this.loadFiles(path);
  71. }
  72. });
  73. }
  74. }
  75. // 初始化框选功能
  76. initSelection() {
  77. if (this.dropZone && this.fileList && this.selectionBox) {
  78. this.selection = new ResourceManagerMultipleSelection({
  79. container: this.dropZone,
  80. itemsContainer: this.fileList,
  81. selectionBox: this.selectionBox,
  82. selectionBar: this.selectionBar,
  83. selectionCount: this.selectionCount,
  84. itemSelector: '.file-item',
  85. onSelectionChange: (selectedItems) => {
  86. // 选择变化时的回调
  87. }
  88. });
  89. }
  90. }
  91. // 初始化快捷键
  92. initShortcutKeys() {
  93. if (this.shortcutKeys) {
  94. this.shortcutKeys.destroy();
  95. }
  96. this.shortcutKeys = new ResourceManagerShortcutKeys({
  97. selection: this.selection,
  98. onDelete: () => this.deleteSelected(),
  99. onRename: () => this.renameSelected()
  100. });
  101. }
  102. // 初始化搜索栏
  103. initSearchBar() {
  104. if (this.searchInput && this.fileList) {
  105. this.searchBar = new ResourceManagerSearchBar({
  106. input: this.searchInput,
  107. clearButton: this.searchClear,
  108. fileList: this.fileList,
  109. emptyState: this.emptyState,
  110. getResources: () => this.files,
  111. renderAll: () => this.renderFiles(),
  112. createFileItem: (file) => this.createFileItem(file),
  113. noResultMessage: '没有找到匹配的资源'
  114. });
  115. }
  116. }
  117. // 初始化右键菜单
  118. initContextMenu() {
  119. if (!this.dropZone || !this.contextMenu) return;
  120. this.contextMenuManager = new ResourceManagerRightClickMenu({
  121. target: this.dropZone,
  122. menu: this.contextMenu,
  123. onAction: (action, event) => this.handleContextMenuAction(action, event),
  124. onBeforeShow: (event) => this.handleBeforeShowContextMenu(event)
  125. });
  126. }
  127. // 显示右键菜单前的处理
  128. handleBeforeShowContextMenu(event) {
  129. const fileItem = event.target.closest('.file-item');
  130. if (!this.contextMenu) return false;
  131. const newBtn = this.contextMenu.querySelector('[data-action="new"]');
  132. const uploadBtn = this.contextMenu.querySelector('[data-action="upload"]');
  133. const backBtn = this.contextMenu.querySelector('[data-action="back"]');
  134. const deleteBtn = this.contextMenu.querySelector('[data-action="delete"]');
  135. const renameBtn = this.contextMenu.querySelector('[data-action="rename"]');
  136. const refreshBtn = this.contextMenu.querySelector('[data-action="refresh"]');
  137. const isRoot = this.currentPath === '';
  138. // 新建分类:只在根目录时显示
  139. if (newBtn) {
  140. newBtn.style.display = isRoot ? 'flex' : 'none';
  141. }
  142. // 上传素材:只在子目录时显示
  143. if (uploadBtn) {
  144. uploadBtn.style.display = isRoot ? 'none' : 'flex';
  145. }
  146. // 返回上级:只在子目录时显示
  147. if (backBtn) {
  148. backBtn.style.display = isRoot ? 'none' : 'flex';
  149. }
  150. // 刷新按钮始终显示
  151. if (refreshBtn) {
  152. refreshBtn.style.display = 'flex';
  153. }
  154. // 如果点击在文件项上
  155. if (fileItem) {
  156. // 确保被点击的项被选中
  157. const isSelected = this.selection && this.selection.isSelected(fileItem.dataset.path);
  158. if (!isSelected && this.selection) {
  159. this.selection.selectOnly(fileItem);
  160. }
  161. if (deleteBtn) deleteBtn.style.display = 'flex';
  162. if (renameBtn) renameBtn.style.display = 'flex';
  163. } else {
  164. if (deleteBtn) deleteBtn.style.display = 'none';
  165. if (renameBtn) renameBtn.style.display = 'none';
  166. }
  167. // 更新分隔线显示
  168. this.updateContextMenuDividers();
  169. return true;
  170. }
  171. // 更新右键菜单分隔线显示
  172. updateContextMenuDividers() {
  173. if (!this.contextMenu) return;
  174. const items = Array.from(this.contextMenu.children);
  175. let lastVisibleWasDivider = true;
  176. items.forEach((item, index) => {
  177. if (item.classList.contains('context-menu-divider')) {
  178. // 如果上一个可见项也是分隔线,或者是第一个,隐藏
  179. if (lastVisibleWasDivider) {
  180. item.style.display = 'none';
  181. } else {
  182. item.style.display = 'block';
  183. lastVisibleWasDivider = true;
  184. }
  185. } else if (item.style.display !== 'none') {
  186. lastVisibleWasDivider = false;
  187. }
  188. });
  189. // 隐藏末尾的分隔线
  190. for (let i = items.length - 1; i >= 0; i--) {
  191. const item = items[i];
  192. if (item.classList.contains('context-menu-divider')) {
  193. if (item.style.display !== 'none') {
  194. item.style.display = 'none';
  195. }
  196. } else if (item.style.display !== 'none') {
  197. break;
  198. }
  199. }
  200. }
  201. // 处理右键菜单操作
  202. handleContextMenuAction(action) {
  203. switch (action) {
  204. case 'new':
  205. this.createFolder();
  206. break;
  207. case 'upload':
  208. // 触发上传
  209. this.triggerUpload();
  210. break;
  211. case 'back':
  212. // 返回上级目录
  213. this.loadFiles('');
  214. break;
  215. case 'delete':
  216. this.deleteSelected();
  217. break;
  218. case 'rename':
  219. this.renameSelected();
  220. break;
  221. case 'refresh':
  222. this.loadFiles(this.currentPath);
  223. break;
  224. }
  225. }
  226. bindEvents() {
  227. // 上传按钮已移除,使用右键菜单或拖拽上传
  228. // 删除按钮
  229. if (this.btnDelete) {
  230. this.btnDelete.addEventListener('click', () => this.deleteSelected());
  231. }
  232. // 拖拽上传
  233. if (this.dropZone) {
  234. console.log('[ResourceManager] 绑定拖拽事件到 dropZone:', this.dropZone);
  235. this.dropZone.addEventListener('dragenter', (e) => this.handleDragEnter(e));
  236. this.dropZone.addEventListener('dragover', (e) => this.handleDragOver(e));
  237. this.dropZone.addEventListener('dragleave', (e) => this.handleDragLeave(e));
  238. this.dropZone.addEventListener('drop', (e) => this.handleDrop(e));
  239. } else {
  240. console.error('[ResourceManager] dropZone 未找到!');
  241. }
  242. }
  243. // 触发上传(动态创建文件输入)
  244. triggerUpload() {
  245. // 创建新的文件输入(每次都重新创建以确保属性正确)
  246. const newInput = document.createElement('input');
  247. newInput.type = 'file';
  248. newInput.style.display = 'none';
  249. // 使用文件夹选择模式
  250. newInput.webkitdirectory = true;
  251. newInput.directory = true;
  252. newInput.multiple = true;
  253. // 移除旧的文件输入
  254. if (this.fileInput) {
  255. this.fileInput.remove();
  256. }
  257. // 添加到DOM
  258. document.body.appendChild(newInput);
  259. this.fileInput = newInput;
  260. // 绑定change事件 - 跳过浏览器确认后直接处理
  261. this.fileInput.addEventListener('change', (e) => this.handleFileSelectDirect(e));
  262. // 触发点击
  263. this.fileInput.click();
  264. }
  265. // 直接处理文件选择(跳过额外确认)
  266. async handleFileSelectDirect(e) {
  267. const files = Array.from(e.target.files);
  268. if (files.length === 0) {
  269. this.fileInput.value = '';
  270. return;
  271. }
  272. // 验证文件夹结构
  273. const validation = this.validateFolderFiles(files);
  274. if (!validation.valid) {
  275. this.showError(validation.errors.join('\n'));
  276. this.fileInput.value = '';
  277. return;
  278. }
  279. // 浏览器已经确认过了,直接上传,不再弹出我们的确认框
  280. const filesToUpload = validation.files.map(file => ({
  281. file: file,
  282. path: file.webkitRelativePath || file.name
  283. }));
  284. try {
  285. await this.uploadFolderFiles(validation.folderName, filesToUpload);
  286. this.showSuccess(`上传成功: ${validation.folderName}`);
  287. await this.loadFiles(this.currentPath);
  288. } catch (error) {
  289. this.showError('上传失败: ' + error.message);
  290. }
  291. this.fileInput.value = '';
  292. }
  293. // 加载文件列表
  294. async loadFiles(path = '') {
  295. this.showLoading(true);
  296. if (this.selection) {
  297. this.selection.clearSelection();
  298. }
  299. if (this.searchBar) {
  300. this.searchBar.clear({ noRender: true });
  301. }
  302. this.currentPath = path;
  303. try {
  304. if (path === '') {
  305. // 根目录:加载分类列表
  306. const response = await fetch(`${this.apiBaseUrl}/api/store/categories`);
  307. if (!response.ok) {
  308. throw new Error(`加载分类失败: ${response.status}`);
  309. }
  310. const result = await response.json();
  311. if (result.success && result.categories) {
  312. this.files = result.categories.map(cat => ({
  313. name: cat.name,
  314. path: cat.dir,
  315. type: 'directory',
  316. isCategory: true
  317. }));
  318. } else {
  319. this.files = [];
  320. }
  321. } else {
  322. // 子目录:加载资源列表
  323. const response = await fetch(`${this.apiBaseUrl}/api/store/resources?category=${encodeURIComponent(path)}`);
  324. if (!response.ok) {
  325. throw new Error(`加载资源失败: ${response.status}`);
  326. }
  327. const result = await response.json();
  328. if (result.success && result.resources) {
  329. this.files = result.resources.map(res => ({
  330. ...res,
  331. type: 'directory'
  332. }));
  333. } else {
  334. this.files = [];
  335. }
  336. }
  337. this.renderFiles();
  338. } catch (error) {
  339. console.error('[ResourceManager] 加载失败:', error);
  340. this.files = [];
  341. this.renderFiles();
  342. this.showError('加载失败: ' + error.message);
  343. } finally {
  344. this.showLoading(false);
  345. }
  346. }
  347. // 渲染文件列表
  348. renderFiles() {
  349. if (!this.fileList) return;
  350. this.fileList.innerHTML = '';
  351. if (this.files.length === 0) {
  352. if (this.emptyState) {
  353. this.emptyState.classList.add('show');
  354. }
  355. return;
  356. }
  357. if (this.emptyState) {
  358. this.emptyState.classList.remove('show');
  359. }
  360. this.files.forEach(file => {
  361. const fileItem = this.createFileItem(file);
  362. this.fileList.appendChild(fileItem);
  363. });
  364. }
  365. // 创建文件项
  366. createFileItem(file) {
  367. const div = document.createElement('div');
  368. div.className = 'file-item';
  369. div.dataset.name = file.name;
  370. div.dataset.type = file.type;
  371. div.dataset.path = file.path;
  372. // 在根目录时,分类文件夹可以拖拽排序
  373. if (this.currentPath === '' && file.isCategory) {
  374. div.draggable = true;
  375. this.setupDragReorder(div, file);
  376. }
  377. // 勾选标记
  378. const checkMark = `
  379. <div class="check-mark">
  380. <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
  381. <path d="M10 3L4.5 8.5 2 6" stroke="white" stroke-width="2" fill="none"/>
  382. </svg>
  383. </div>
  384. `;
  385. // 检查是否有预览图
  386. if (file.previewUrl) {
  387. div.innerHTML = `
  388. ${checkMark}
  389. <div class="file-thumbnail folder-preview">
  390. <img src="${this.apiBaseUrl}${file.previewUrl}" alt="${file.name}" loading="lazy">
  391. <div class="folder-badge">📁</div>
  392. </div>
  393. <div class="file-name">${file.name}</div>
  394. <input type="text" class="rename-input" style="display: none;">
  395. `;
  396. } else {
  397. div.innerHTML = `
  398. ${checkMark}
  399. <div class="file-icon folder-icon">
  400. <svg viewBox="0 0 64 64" fill="currentColor">
  401. <path d="M8 12h20l4 6h24a4 4 0 014 4v28a4 4 0 01-4 4H8a4 4 0 01-4-4V16a4 4 0 014-4z"/>
  402. </svg>
  403. </div>
  404. <div class="file-name">${file.name}</div>
  405. <input type="text" class="rename-input" style="display: none;">
  406. `;
  407. }
  408. // 点击事件
  409. let clickTimer = null;
  410. div.addEventListener('click', (e) => {
  411. if (e.target.classList.contains('rename-input')) return;
  412. e.stopPropagation();
  413. const isAlreadySelected = this.selection && this.selection.isSelected(file.path);
  414. const clickedOnName = e.target.classList.contains('file-name');
  415. const ctrlKey = e.ctrlKey;
  416. if (clickTimer) {
  417. clearTimeout(clickTimer);
  418. clickTimer = null;
  419. }
  420. clickTimer = setTimeout(() => {
  421. clickTimer = null;
  422. if (ctrlKey && this.selection) {
  423. this.selection.toggleSelection(div);
  424. } else if (clickedOnName && isAlreadySelected) {
  425. this.startRename(div);
  426. } else if (this.selection) {
  427. this.selection.selectOnly(div);
  428. }
  429. }, 200);
  430. });
  431. // 双击进入文件夹
  432. div.addEventListener('dblclick', (e) => {
  433. if (e.target.classList.contains('rename-input')) return;
  434. e.stopPropagation();
  435. if (clickTimer) {
  436. clearTimeout(clickTimer);
  437. clickTimer = null;
  438. }
  439. if (file.type === 'directory') {
  440. if (file.isCategory) {
  441. this.pathNav.navigateTo(file.path);
  442. } else {
  443. // 资源文件夹可以进一步打开查看
  444. // 或者在这里可以打开预览
  445. }
  446. }
  447. });
  448. return div;
  449. }
  450. // 开始重命名
  451. startRename(fileItem) {
  452. const nameEl = fileItem.querySelector('.file-name');
  453. const input = fileItem.querySelector('.rename-input');
  454. if (!nameEl || !input) return;
  455. const oldName = fileItem.dataset.name;
  456. const filePath = fileItem.dataset.path;
  457. input.value = oldName;
  458. nameEl.style.display = 'none';
  459. input.style.display = '';
  460. setTimeout(() => {
  461. const dotIndex = oldName.lastIndexOf('.');
  462. if (dotIndex > 0 && fileItem.dataset.type !== 'directory') {
  463. input.setSelectionRange(0, dotIndex);
  464. } else {
  465. input.select();
  466. }
  467. input.focus();
  468. }, 10);
  469. let finished = false;
  470. const exitRename = () => {
  471. input.style.display = 'none';
  472. nameEl.style.display = '';
  473. };
  474. const commitRename = async () => {
  475. if (finished) return;
  476. finished = true;
  477. const newName = input.value.trim();
  478. exitRename();
  479. if (newName && newName !== oldName) {
  480. await this.renameFile(filePath, newName);
  481. }
  482. };
  483. const cancelRename = () => {
  484. if (finished) return;
  485. finished = true;
  486. exitRename();
  487. };
  488. const handleBlur = () => {
  489. input.removeEventListener('blur', handleBlur);
  490. input.removeEventListener('keydown', handleKeydown);
  491. if (!finished) {
  492. commitRename();
  493. }
  494. };
  495. const handleKeydown = (e) => {
  496. if (e.key === 'Enter') {
  497. e.preventDefault();
  498. e.stopPropagation();
  499. input.removeEventListener('blur', handleBlur);
  500. input.removeEventListener('keydown', handleKeydown);
  501. commitRename();
  502. } else if (e.key === 'Escape') {
  503. e.preventDefault();
  504. e.stopPropagation();
  505. input.removeEventListener('blur', handleBlur);
  506. input.removeEventListener('keydown', handleKeydown);
  507. cancelRename();
  508. }
  509. };
  510. input.addEventListener('blur', handleBlur);
  511. input.addEventListener('keydown', handleKeydown);
  512. }
  513. // 重命名文件
  514. async renameFile(oldPath, newName) {
  515. try {
  516. const response = await fetch(`${this.apiBaseUrl}/api/admin/store/rename`, {
  517. method: 'POST',
  518. headers: { 'Content-Type': 'application/json' },
  519. body: JSON.stringify({ resourcePath: oldPath, newName })
  520. });
  521. const result = await response.json();
  522. if (result.success) {
  523. this.showSuccess('重命名成功');
  524. await this.loadFiles(this.currentPath);
  525. } else {
  526. this.showError('重命名失败: ' + (result.message || '未知错误'));
  527. await this.loadFiles(this.currentPath);
  528. }
  529. } catch (error) {
  530. this.showError('重命名失败: ' + error.message);
  531. await this.loadFiles(this.currentPath);
  532. }
  533. }
  534. // 重命名选中项
  535. renameSelected() {
  536. if (!this.selection || this.selection.getSelectedCount() !== 1) return;
  537. const selectedPath = this.selection.getSelectedItems()[0];
  538. const selectedItem = this.fileList.querySelector(`[data-path="${selectedPath}"]`);
  539. if (selectedItem) {
  540. this.startRename(selectedItem);
  541. }
  542. }
  543. // 删除选中项
  544. async deleteSelected() {
  545. console.log('[ResourceManager] deleteSelected 被调用');
  546. console.log('[ResourceManager] this.selection:', this.selection);
  547. if (!this.selection) {
  548. console.log('[ResourceManager] selection 为空,退出');
  549. return;
  550. }
  551. const count = this.selection.getSelectedCount();
  552. console.log('[ResourceManager] 选中数量:', count);
  553. if (count === 0) {
  554. console.log('[ResourceManager] 没有选中项,退出');
  555. return;
  556. }
  557. const confirmed = await this.showConfirm(`确定要删除选中的 ${count} 个文件/文件夹吗?`);
  558. if (!confirmed) return;
  559. try {
  560. const selectedPaths = this.selection.getSelectedItems();
  561. for (const path of selectedPaths) {
  562. const response = await fetch(`${this.apiBaseUrl}/api/admin/store/delete`, {
  563. method: 'POST',
  564. headers: { 'Content-Type': 'application/json' },
  565. body: JSON.stringify({ resourcePath: path })
  566. });
  567. const result = await response.json();
  568. if (!result.success) {
  569. this.showError('删除失败: ' + (result.message || '未知错误'));
  570. }
  571. }
  572. this.selection.clearSelection();
  573. await this.loadFiles(this.currentPath);
  574. this.showSuccess('删除成功');
  575. } catch (error) {
  576. this.showError('删除失败: ' + error.message);
  577. }
  578. }
  579. // 设置拖拽排序
  580. setupDragReorder(div, file) {
  581. div.addEventListener('dragstart', (e) => {
  582. e.stopPropagation();
  583. div.classList.add('dragging');
  584. e.dataTransfer.effectAllowed = 'move';
  585. e.dataTransfer.setData('text/plain', JSON.stringify({
  586. type: 'reorder',
  587. name: file.name
  588. }));
  589. });
  590. div.addEventListener('dragend', () => {
  591. div.classList.remove('dragging');
  592. // 移除所有拖拽目标样式
  593. this.fileList.querySelectorAll('.file-item.drag-over-left, .file-item.drag-over-right').forEach(el => {
  594. el.classList.remove('drag-over-left', 'drag-over-right');
  595. });
  596. // 确保移除上传提示
  597. if (this.dropZone) {
  598. this.dropZone.classList.remove('drag-over');
  599. }
  600. });
  601. div.addEventListener('dragover', (e) => {
  602. e.preventDefault();
  603. // 只有内部拖拽排序时才处理
  604. const draggingEl = this.fileList.querySelector('.file-item.dragging');
  605. if (!draggingEl || draggingEl === div) {
  606. // 外部文件拖入时,不阻止冒泡,让 dropZone 处理
  607. return;
  608. }
  609. e.stopPropagation();
  610. e.dataTransfer.dropEffect = 'move';
  611. // 判断鼠标在元素的左半边还是右半边
  612. const rect = div.getBoundingClientRect();
  613. const midX = rect.left + rect.width / 2;
  614. div.classList.remove('drag-over-left', 'drag-over-right');
  615. if (e.clientX < midX) {
  616. div.classList.add('drag-over-left');
  617. } else {
  618. div.classList.add('drag-over-right');
  619. }
  620. });
  621. div.addEventListener('dragleave', (e) => {
  622. // 只有内部拖拽排序时才阻止冒泡
  623. const draggingEl = this.fileList.querySelector('.file-item.dragging');
  624. if (draggingEl) {
  625. e.stopPropagation();
  626. }
  627. div.classList.remove('drag-over-left', 'drag-over-right');
  628. });
  629. div.addEventListener('drop', async (e) => {
  630. e.preventDefault();
  631. // 如果是外部文件拖入,让事件继续冒泡到 dropZone
  632. if (e.dataTransfer.types.includes('Files') && !e.dataTransfer.getData('text/plain')) {
  633. console.log('[ResourceManager] 外部文件拖入到文件项,转发到 dropZone');
  634. this.handleDrop(e);
  635. return;
  636. }
  637. e.stopPropagation();
  638. const isLeft = div.classList.contains('drag-over-left');
  639. div.classList.remove('drag-over-left', 'drag-over-right');
  640. try {
  641. const data = JSON.parse(e.dataTransfer.getData('text/plain'));
  642. if (data.type !== 'reorder') return;
  643. const draggedName = data.name;
  644. const targetName = file.name;
  645. if (draggedName === targetName) return;
  646. // 重新计算顺序
  647. const currentOrder = this.files.map(f => f.name);
  648. const draggedIndex = currentOrder.indexOf(draggedName);
  649. let targetIndex = currentOrder.indexOf(targetName);
  650. if (draggedIndex === -1 || targetIndex === -1) return;
  651. // 移除拖拽的元素
  652. currentOrder.splice(draggedIndex, 1);
  653. // 重新计算目标位置(因为移除了一个元素)
  654. targetIndex = currentOrder.indexOf(targetName);
  655. // 插入到目标位置
  656. if (isLeft) {
  657. currentOrder.splice(targetIndex, 0, draggedName);
  658. } else {
  659. currentOrder.splice(targetIndex + 1, 0, draggedName);
  660. }
  661. // 保存新顺序
  662. await this.saveCategoryOrder(currentOrder);
  663. } catch (error) {
  664. console.error('[ResourceManager] 拖拽排序失败:', error);
  665. }
  666. });
  667. }
  668. // 保存分类排序
  669. async saveCategoryOrder(order) {
  670. try {
  671. const response = await fetch(`${this.apiBaseUrl}/api/admin/store/update-order`, {
  672. method: 'POST',
  673. headers: { 'Content-Type': 'application/json' },
  674. body: JSON.stringify({ order })
  675. });
  676. const result = await response.json();
  677. if (result.success) {
  678. this.showSuccess('排序已保存');
  679. await this.loadFiles('');
  680. } else {
  681. this.showError('保存排序失败: ' + (result.message || '未知错误'));
  682. }
  683. } catch (error) {
  684. this.showError('保存排序失败: ' + error.message);
  685. }
  686. }
  687. // 创建文件夹
  688. async createFolder() {
  689. if (this.currentPath !== '') {
  690. this.showError('只能在根目录创建分类文件夹');
  691. return;
  692. }
  693. const folderName = await this.showPrompt('请输入分类文件夹名称:');
  694. if (!folderName || !folderName.trim()) return;
  695. const name = folderName.trim();
  696. if (/[\\/:*?"<>|]/.test(name)) {
  697. this.showError('文件夹名称包含非法字符');
  698. return;
  699. }
  700. try {
  701. const response = await fetch(`${this.apiBaseUrl}/api/admin/store/create-folder`, {
  702. method: 'POST',
  703. headers: { 'Content-Type': 'application/json' },
  704. body: JSON.stringify({ name })
  705. });
  706. const result = await response.json();
  707. if (result.success) {
  708. this.showSuccess('创建文件夹成功');
  709. await this.loadFiles('');
  710. } else {
  711. this.showError('创建文件夹失败: ' + (result.message || '未知错误'));
  712. }
  713. } catch (error) {
  714. this.showError('创建文件夹失败: ' + error.message);
  715. }
  716. }
  717. // 处理文件选择(始终为文件夹模式)
  718. async handleFileSelect(e) {
  719. const files = Array.from(e.target.files);
  720. if (files.length === 0) {
  721. this.fileInput.value = '';
  722. return;
  723. }
  724. // 验证文件夹结构
  725. const validation = this.validateFolderFiles(files);
  726. if (!validation.valid) {
  727. this.showError(validation.errors.join('\n'));
  728. this.fileInput.value = '';
  729. return;
  730. }
  731. const targetPath = this.currentPath || validation.folderName;
  732. const confirmMsg = this.currentPath
  733. ? `将上传文件夹 "${validation.folderName}" 到分类 "${this.currentPath}",包含 ${validation.files.length} 个PNG图片,确认上传?`
  734. : `将上传文件夹 "${validation.folderName}",包含 ${validation.files.length} 个PNG图片,确认上传?`;
  735. const confirmed = await this.showConfirm(confirmMsg);
  736. if (!confirmed) {
  737. this.fileInput.value = '';
  738. return;
  739. }
  740. // 根据当前位置决定上传方式
  741. // 转换 validation.files (File[]) 为 {file, path}[] 格式
  742. const filesToUpload = validation.files.map(file => ({
  743. file: file,
  744. path: file.webkitRelativePath || file.name
  745. }));
  746. if (this.currentPath === '') {
  747. // 在根目录上传,文件夹名就是分类名
  748. await this.uploadFolderFiles(validation.folderName, filesToUpload);
  749. } else {
  750. // 在子目录上传
  751. await this.uploadFolderFiles(validation.folderName, filesToUpload);
  752. }
  753. this.fileInput.value = '';
  754. }
  755. // 验证通过文件输入选择的文件夹
  756. validateFolderFiles(files) {
  757. const errors = [];
  758. const validFiles = [];
  759. let folderName = '';
  760. // 获取文件夹名(第一层目录)
  761. const paths = new Set();
  762. for (const file of files) {
  763. const relativePath = file.webkitRelativePath || file.name;
  764. const parts = relativePath.split('/');
  765. if (parts.length > 0) {
  766. paths.add(parts[0]);
  767. if (!folderName) folderName = parts[0];
  768. }
  769. }
  770. // 检查是否只有一个顶级文件夹
  771. if (paths.size > 1) {
  772. errors.push('请只选择一个文件夹');
  773. return { valid: false, errors, files: [], folderName: '' };
  774. }
  775. for (const file of files) {
  776. const relativePath = file.webkitRelativePath || file.name;
  777. const parts = relativePath.split('/');
  778. // 检查是否有子文件夹(路径深度超过2层)
  779. if (parts.length > 2) {
  780. errors.push(`不允许有子文件夹: ${parts.slice(0, -1).join('/')}`);
  781. continue;
  782. }
  783. // 检查文件类型
  784. const fileName = file.name.toLowerCase();
  785. if (!fileName.endsWith('.png')) {
  786. errors.push(`文件 "${file.name}" 不是PNG格式,只允许PNG图片`);
  787. continue;
  788. }
  789. validFiles.push(file);
  790. }
  791. if (validFiles.length === 0 && errors.length === 0) {
  792. errors.push('文件夹为空或没有PNG图片');
  793. }
  794. // 只显示前5个错误
  795. const displayErrors = errors.slice(0, 5);
  796. if (errors.length > 5) {
  797. displayErrors.push(`... 还有 ${errors.length - 5} 个错误`);
  798. }
  799. return {
  800. valid: errors.length === 0,
  801. errors: displayErrors,
  802. files: validFiles,
  803. folderName
  804. };
  805. }
  806. // 上传文件
  807. async uploadFiles(files) {
  808. if (files.length === 0) return;
  809. // 获取目标路径
  810. const category = this.currentPath || files[0].webkitRelativePath?.split('/')[0] || 'default';
  811. const folderName = files[0].webkitRelativePath?.split('/')[0] || 'upload_' + Date.now();
  812. const formData = new FormData();
  813. formData.append('category', category);
  814. formData.append('name', folderName);
  815. formData.append('price', 0);
  816. for (const file of files) {
  817. formData.append('files', file);
  818. }
  819. try {
  820. const response = await fetch(`${this.apiBaseUrl}/api/admin/store/upload`, {
  821. method: 'POST',
  822. body: formData
  823. });
  824. const result = await response.json();
  825. if (result.success) {
  826. this.showSuccess('上传成功');
  827. await this.loadFiles(this.currentPath);
  828. } else {
  829. this.showError('上传失败: ' + (result.message || '未知错误'));
  830. }
  831. } catch (error) {
  832. this.showError('上传失败: ' + error.message);
  833. }
  834. }
  835. // 检查是否是外部文件拖入(而非内部排序拖拽)
  836. isExternalFileDrag(e) {
  837. // 如果有正在拖拽的内部元素,说明是内部排序
  838. if (this.fileList && this.fileList.querySelector('.file-item.dragging')) {
  839. return false;
  840. }
  841. // 检查是否有文件类型
  842. return e.dataTransfer.types.includes('Files');
  843. }
  844. // 拖拽处理
  845. handleDragEnter(e) {
  846. e.preventDefault();
  847. e.stopPropagation();
  848. console.log('[ResourceManager] dragenter 触发');
  849. // 只有外部文件拖入时才显示上传提示
  850. if (this.isExternalFileDrag(e)) {
  851. this.dropZone.classList.add('drag-over');
  852. }
  853. }
  854. handleDragOver(e) {
  855. e.preventDefault();
  856. e.stopPropagation();
  857. }
  858. handleDragLeave(e) {
  859. e.preventDefault();
  860. e.stopPropagation();
  861. // 检查是否真正离开了 dropZone(而不是进入子元素)
  862. if (!this.dropZone.contains(e.relatedTarget)) {
  863. this.dropZone.classList.remove('drag-over');
  864. }
  865. }
  866. async handleDrop(e) {
  867. console.log('[ResourceManager] ===== handleDrop 触发 =====');
  868. e.preventDefault();
  869. e.stopPropagation();
  870. this.dropZone.classList.remove('drag-over');
  871. console.log('[ResourceManager] dataTransfer.types:', Array.from(e.dataTransfer.types));
  872. console.log('[ResourceManager] isExternalFileDrag:', this.isExternalFileDrag(e));
  873. // 如果是内部排序拖拽,不处理文件上传
  874. if (!this.isExternalFileDrag(e)) {
  875. console.log('[ResourceManager] 是内部拖拽,跳过');
  876. return;
  877. }
  878. const items = e.dataTransfer.items;
  879. console.log('[ResourceManager] items:', items, 'length:', items ? items.length : 0);
  880. if (!items) return;
  881. const entries = [];
  882. for (let i = 0; i < items.length; i++) {
  883. const item = items[i].webkitGetAsEntry();
  884. console.log(`[ResourceManager] entry ${i}:`, item ? item.name : 'null');
  885. if (item) {
  886. entries.push(item);
  887. }
  888. }
  889. console.log('[ResourceManager] 共收集到', entries.length, '个 entries');
  890. await this.processDropEntries(entries);
  891. }
  892. // 处理拖入的 entries(完全按照 client/js/disk/disk.js 的方式)
  893. async processDropEntries(entries) {
  894. console.log('[ResourceManager] processDropEntries 开始处理', entries.length, '个 entries');
  895. // 检查是否有非文件夹
  896. const hasFiles = entries.some(entry => entry.isFile);
  897. if (hasFiles) {
  898. this.showError('只能拖拽文件夹,不能拖拽单个文件');
  899. return;
  900. }
  901. const filesToUpload = [];
  902. for (const entry of entries) {
  903. console.log('[ResourceManager] 开始遍历:', entry.name);
  904. try {
  905. await this.traverseEntry(entry, '', filesToUpload);
  906. console.log('[ResourceManager] 遍历完成:', entry.name, '当前文件数:', filesToUpload.length);
  907. } catch (err) {
  908. console.error('[ResourceManager] 遍历失败:', entry.name, err);
  909. }
  910. }
  911. console.log('[ResourceManager] 总共收集到', filesToUpload.length, '个 PNG 文件');
  912. if (filesToUpload.length > 0) {
  913. // 按文件夹分组
  914. const folderMap = new Map();
  915. for (const item of filesToUpload) {
  916. const folderName = item.path.split('/')[0];
  917. if (!folderMap.has(folderName)) {
  918. folderMap.set(folderName, []);
  919. }
  920. folderMap.get(folderName).push(item);
  921. }
  922. const folderNames = Array.from(folderMap.keys());
  923. const confirmMsg = `将上传 ${folderMap.size} 个文件夹(${folderNames.slice(0, 5).join('、')}${folderNames.length > 5 ? '...' : ''}),共 ${filesToUpload.length} 个PNG图片\n\n确认上传?`;
  924. const confirmed = await this.showConfirm(confirmMsg);
  925. if (!confirmed) {
  926. return;
  927. }
  928. // 按文件夹逐个上传,每上传完一个就刷新显示
  929. let successCount = 0;
  930. let failCount = 0;
  931. const totalCount = folderMap.size;
  932. for (const [folderName, files] of folderMap) {
  933. try {
  934. await this.uploadFolderFiles(folderName, files);
  935. successCount++;
  936. // 每上传完一个文件夹就刷新显示
  937. await this.loadFiles(this.currentPath);
  938. this.showInfo(`上传进度: ${successCount}/${totalCount} - ${folderName}`);
  939. } catch (error) {
  940. console.error(`[ResourceManager] 上传 ${folderName} 失败:`, error);
  941. failCount++;
  942. }
  943. }
  944. if (failCount === 0) {
  945. this.showSuccess(`成功上传 ${successCount} 个文件夹`);
  946. } else {
  947. this.showWarning(`上传完成: ${successCount} 成功, ${failCount} 失败`);
  948. }
  949. } else {
  950. console.log('[ResourceManager] 没有找到可上传的PNG图片');
  951. this.showError('没有找到可上传的PNG图片');
  952. }
  953. }
  954. // 递归遍历文件夹
  955. async traverseEntry(entry, relativePath, filesToUpload) {
  956. console.log('[ResourceManager] traverseEntry:', entry.name, 'isFile:', entry.isFile, 'isDirectory:', entry.isDirectory);
  957. if (entry.isFile) {
  958. console.log('[ResourceManager] 读取文件:', entry.name);
  959. const file = await new Promise((resolve, reject) => {
  960. entry.file(resolve, reject);
  961. });
  962. console.log('[ResourceManager] 文件读取完成:', file.name, file.size);
  963. // 只接受 PNG 文件
  964. if (file.name.toLowerCase().endsWith('.png')) {
  965. filesToUpload.push({
  966. file: file,
  967. path: relativePath + file.name
  968. });
  969. console.log('[ResourceManager] 添加PNG:', relativePath + file.name);
  970. }
  971. } else if (entry.isDirectory) {
  972. console.log('[ResourceManager] 开始读取目录:', entry.name);
  973. const dirReader = entry.createReader();
  974. // 读取所有条目 - readEntries 每次最多返回100条,需要循环读取
  975. let allEntries = [];
  976. let batch;
  977. let readCount = 0;
  978. do {
  979. console.log('[ResourceManager] 调用 readEntries 第', readCount + 1, '次');
  980. batch = await new Promise((resolve) => {
  981. dirReader.readEntries(
  982. (entries) => {
  983. console.log('[ResourceManager] readEntries 成功, 返回', entries.length, '条');
  984. resolve(entries);
  985. },
  986. (err) => {
  987. console.error('[ResourceManager] readEntries 错误:', err);
  988. resolve([]); // 出错返回空数组继续
  989. }
  990. );
  991. });
  992. allEntries = allEntries.concat(batch);
  993. readCount++;
  994. } while (batch.length > 0);
  995. console.log('[ResourceManager] 目录', entry.name, '共有', allEntries.length, '个条目');
  996. for (const childEntry of allEntries) {
  997. await this.traverseEntry(
  998. childEntry,
  999. relativePath + entry.name + '/',
  1000. filesToUpload
  1001. );
  1002. }
  1003. console.log('[ResourceManager] 目录', entry.name, '遍历完成');
  1004. }
  1005. }
  1006. // 上传单个文件夹的文件
  1007. async uploadFolderFiles(folderName, files) {
  1008. if (files.length === 0) return;
  1009. const category = this.currentPath || folderName;
  1010. const formData = new FormData();
  1011. formData.append('category', category);
  1012. formData.append('name', folderName);
  1013. formData.append('price', '0');
  1014. for (const item of files) {
  1015. formData.append('files', item.file, item.path);
  1016. }
  1017. const response = await fetch(`${this.apiBaseUrl}/api/admin/store/upload`, {
  1018. method: 'POST',
  1019. body: formData
  1020. });
  1021. const result = await response.json();
  1022. if (!result.success) {
  1023. throw new Error(result.message || '上传失败');
  1024. }
  1025. return result;
  1026. }
  1027. isImageFile(fileName) {
  1028. const ext = fileName.split('.').pop().toLowerCase();
  1029. return ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(ext);
  1030. }
  1031. showLoading(show) {
  1032. if (this.loading) {
  1033. if (show) {
  1034. this.loading.classList.add('show');
  1035. } else {
  1036. this.loading.classList.remove('show');
  1037. }
  1038. }
  1039. }
  1040. // 显示提示消息
  1041. showHint(message, type = 'info') {
  1042. const hintView = document.getElementById('hintView');
  1043. const hintMessage = document.getElementById('hintMessage');
  1044. if (!hintView || !hintMessage) {
  1045. console.log(`[${type}]`, message);
  1046. return;
  1047. }
  1048. // 移除之前的类型类
  1049. hintView.classList.remove('success', 'error', 'warning', 'info');
  1050. hintView.classList.add(type);
  1051. hintMessage.textContent = message;
  1052. hintView.classList.remove('hide');
  1053. hintView.classList.add('show');
  1054. // 自动隐藏
  1055. setTimeout(() => {
  1056. hintView.classList.remove('show');
  1057. hintView.classList.add('hide');
  1058. }, 3000);
  1059. }
  1060. showError(message) {
  1061. this.showHint(message, 'error');
  1062. }
  1063. showSuccess(message) {
  1064. this.showHint(message, 'success');
  1065. }
  1066. showWarning(message) {
  1067. this.showHint(message, 'warning');
  1068. }
  1069. showInfo(message) {
  1070. this.showHint(message, 'info');
  1071. }
  1072. // 显示确认对话框
  1073. showConfirm(message) {
  1074. return new Promise((resolve) => {
  1075. const overlay = document.getElementById('globalConfirmOverlay');
  1076. const messageEl = document.getElementById('confirmMessage');
  1077. const okBtn = document.getElementById('confirmOkBtn');
  1078. const cancelBtn = document.getElementById('confirmCancelBtn');
  1079. if (!overlay || !messageEl || !okBtn || !cancelBtn) {
  1080. // 降级到原生 confirm
  1081. resolve(confirm(message));
  1082. return;
  1083. }
  1084. messageEl.textContent = message;
  1085. overlay.classList.add('show');
  1086. const cleanup = () => {
  1087. overlay.classList.remove('show');
  1088. okBtn.removeEventListener('click', handleOk);
  1089. cancelBtn.removeEventListener('click', handleCancel);
  1090. };
  1091. const handleOk = () => {
  1092. cleanup();
  1093. resolve(true);
  1094. };
  1095. const handleCancel = () => {
  1096. cleanup();
  1097. resolve(false);
  1098. };
  1099. okBtn.addEventListener('click', handleOk);
  1100. cancelBtn.addEventListener('click', handleCancel);
  1101. });
  1102. }
  1103. // 显示输入对话框
  1104. showPrompt(message, defaultValue = '') {
  1105. return new Promise((resolve) => {
  1106. const overlay = document.getElementById('globalPromptOverlay');
  1107. const messageEl = document.getElementById('promptMessage');
  1108. const input = document.getElementById('promptInput');
  1109. const okBtn = document.getElementById('promptOkBtn');
  1110. const cancelBtn = document.getElementById('promptCancelBtn');
  1111. if (!overlay || !messageEl || !input || !okBtn || !cancelBtn) {
  1112. // 降级到原生 prompt
  1113. resolve(prompt(message, defaultValue));
  1114. return;
  1115. }
  1116. messageEl.textContent = message;
  1117. input.value = defaultValue;
  1118. overlay.classList.add('show');
  1119. // 自动聚焦输入框
  1120. setTimeout(() => {
  1121. input.focus();
  1122. input.select();
  1123. }, 100);
  1124. const cleanup = () => {
  1125. overlay.classList.remove('show');
  1126. okBtn.removeEventListener('click', handleOk);
  1127. cancelBtn.removeEventListener('click', handleCancel);
  1128. input.removeEventListener('keydown', handleKeydown);
  1129. };
  1130. const handleOk = () => {
  1131. const value = input.value;
  1132. cleanup();
  1133. resolve(value);
  1134. };
  1135. const handleCancel = () => {
  1136. cleanup();
  1137. resolve(null);
  1138. };
  1139. const handleKeydown = (e) => {
  1140. if (e.key === 'Enter') {
  1141. e.preventDefault();
  1142. handleOk();
  1143. } else if (e.key === 'Escape') {
  1144. e.preventDefault();
  1145. handleCancel();
  1146. }
  1147. };
  1148. okBtn.addEventListener('click', handleOk);
  1149. cancelBtn.addEventListener('click', handleCancel);
  1150. input.addEventListener('keydown', handleKeydown);
  1151. });
  1152. }
  1153. }
  1154. // 导出
  1155. if (typeof module !== 'undefined' && module.exports) {
  1156. module.exports = ResourceManager;
  1157. } else {
  1158. window.ResourceManager = ResourceManager;
  1159. }