Spaces:
Running
Running
| /* ===== Batch Selection & Actions ===== */ | |
| window.NAIBatch = { | |
| /** | |
| * Create a batch controller for a gallery page. | |
| * @param {object} opts | |
| * - gridSel: grid container selector | |
| * - toolbarSel: toolbar container selector | |
| * - getItems: () => [{id, ...}] all items | |
| * - renderCard: (item, isSelected, batchMode) => HTMLElement | |
| * - onDelete: async (ids) => void | |
| * - onDownload: async (ids) => void (optional, we provide default ZIP) | |
| * - getImageB64: async (id) => {b64, filename} | |
| * - getName: (item) => string filename | |
| */ | |
| create(opts) { | |
| const ctrl = { | |
| opts, | |
| batchMode: false, | |
| selected: new Set(), | |
| toggle() { | |
| this.batchMode = !this.batchMode; | |
| if (!this.batchMode) this.selected.clear(); | |
| this.updateToolbar(); | |
| this.refreshGrid(); | |
| }, | |
| selectAll() { | |
| const items = this.opts.getItems(); | |
| items.forEach(item => this.selected.add(item.id ?? item.path)); | |
| this.updateToolbar(); | |
| this.refreshGrid(); | |
| }, | |
| selectNone() { | |
| this.selected.clear(); | |
| this.updateToolbar(); | |
| this.refreshGrid(); | |
| }, | |
| toggleItem(id) { | |
| if (this.selected.has(id)) { | |
| this.selected.delete(id); | |
| } else { | |
| this.selected.add(id); | |
| } | |
| this.updateToolbar(); | |
| // Update just the card | |
| const card = document.querySelector(`[data-batch-id="${id}"]`); | |
| if (card) { | |
| card.classList.toggle('batch-selected', this.selected.has(id)); | |
| const cb = card.querySelector('.batch-checkbox'); | |
| if (cb) cb.checked = this.selected.has(id); | |
| } | |
| }, | |
| // Shift-click range select | |
| _lastClickedId: null, | |
| rangeSelect(id) { | |
| const items = this.opts.getItems(); | |
| const ids = items.map(i => i.id ?? i.path); | |
| const curIdx = ids.indexOf(id); | |
| const lastIdx = ids.indexOf(this._lastClickedId); | |
| if (lastIdx === -1 || curIdx === -1) { | |
| this.toggleItem(id); | |
| this._lastClickedId = id; | |
| return; | |
| } | |
| const start = Math.min(curIdx, lastIdx); | |
| const end = Math.max(curIdx, lastIdx); | |
| for (let i = start; i <= end; i++) { | |
| this.selected.add(ids[i]); | |
| } | |
| this._lastClickedId = id; | |
| this.updateToolbar(); | |
| this.refreshGrid(); | |
| }, | |
| updateToolbar() { | |
| const toolbar = document.querySelector(this.opts.toolbarSel); | |
| if (!toolbar) return; | |
| if (!this.batchMode) { | |
| toolbar.style.display = 'none'; | |
| return; | |
| } | |
| toolbar.style.display = 'flex'; | |
| const count = this.selected.size; | |
| const total = this.opts.getItems().length; | |
| const countEl = toolbar.querySelector('.batch-count'); | |
| if (countEl) countEl.textContent = `已选 ${count} / ${total}`; | |
| const allCb = toolbar.querySelector('.batch-select-all'); | |
| if (allCb) { | |
| allCb.checked = count > 0 && count === total; | |
| allCb.indeterminate = count > 0 && count < total; | |
| } | |
| // Disable actions when nothing selected | |
| toolbar.querySelectorAll('.batch-action-btn').forEach(btn => { | |
| btn.disabled = count === 0; | |
| }); | |
| }, | |
| refreshGrid() { | |
| // Re-render grid via the host page | |
| if (this.opts.onRefreshGrid) this.opts.onRefreshGrid(); | |
| }, | |
| async batchDelete() { | |
| const count = this.selected.size; | |
| if (count === 0) return; | |
| if (!confirm(`确定删除选中的 ${count} 张图片?`)) return; | |
| const ids = [...this.selected]; | |
| try { | |
| await this.opts.onDelete(ids); | |
| this.selected.clear(); | |
| this.updateToolbar(); | |
| } catch(e) { | |
| console.error('Batch delete failed:', e); | |
| alert('部分删除失败,请重试'); | |
| } | |
| }, | |
| async batchDownload() { | |
| const count = this.selected.size; | |
| if (count === 0) return; | |
| const ids = [...this.selected]; | |
| const toolbar = document.querySelector(this.opts.toolbarSel); | |
| const progressEl = toolbar?.querySelector('.batch-progress'); | |
| // Use JSZip to create ZIP in browser | |
| if (typeof JSZip === 'undefined') { | |
| alert('JSZip 未加载,无法打包下载'); | |
| return; | |
| } | |
| const zip = new JSZip(); | |
| let loaded = 0; | |
| if (progressEl) { | |
| progressEl.style.display = ''; | |
| progressEl.textContent = `打包中 0/${count}`; | |
| } | |
| for (const id of ids) { | |
| try { | |
| const result = await this.opts.getImageB64(id); | |
| if (result && result.b64) { | |
| zip.file(result.filename, result.b64, { base64: true }); | |
| } | |
| } catch(e) { | |
| console.error('Failed to fetch image:', id, e); | |
| } | |
| loaded++; | |
| if (progressEl) progressEl.textContent = `打包中 ${loaded}/${count}`; | |
| } | |
| if (progressEl) progressEl.textContent = '生成ZIP...'; | |
| try { | |
| const blob = await zip.generateAsync({ | |
| type: 'blob', | |
| compression: 'STORE', | |
| }, (meta) => { | |
| if (progressEl) progressEl.textContent = `压缩 ${Math.round(meta.percent)}%`; | |
| }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| const now = new Date(); | |
| a.download = `nai_batch_${now.getFullYear()}${String(now.getMonth()+1).padStart(2,'0')}${String(now.getDate()).padStart(2,'0')}.zip`; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| } catch(e) { | |
| console.error('ZIP generation failed:', e); | |
| alert('ZIP生成失败'); | |
| } | |
| if (progressEl) { | |
| progressEl.textContent = '完成'; | |
| setTimeout(() => { progressEl.style.display = 'none'; }, 2000); | |
| } | |
| }, | |
| }; | |
| return ctrl; | |
| }, | |
| }; |