Add bulk operation

This commit is contained in:
Will Miao
2025-03-07 13:15:39 +08:00
parent 69b1773ced
commit 0c4914909a
10 changed files with 471 additions and 44 deletions

View File

@@ -43,6 +43,7 @@ class ApiRoutes:
app.router.add_post('/api/move_model', routes.move_model)
app.router.add_post('/loras/api/save-metadata', routes.save_metadata)
app.router.add_get('/api/lora-preview-url', routes.get_lora_preview_url) # Add new route
app.router.add_post('/api/move_models_bulk', routes.move_models_bulk)
# Add update check routes
UpdateRoutes.setup_routes(app)
@@ -654,3 +655,39 @@ class ApiRoutes:
except Exception as e:
logger.error(f"Error getting lora preview URL: {e}", exc_info=True)
return web.Response(text=str(e), status=500)
async def move_models_bulk(self, request: web.Request) -> web.Response:
"""Handle bulk model move request"""
try:
data = await request.json()
file_paths = data.get('file_paths', [])
target_path = data.get('target_path')
if not file_paths or not target_path:
return web.Response(text='File paths and target path are required', status=400)
results = []
for file_path in file_paths:
success = await self.scanner.move_model(file_path, target_path)
results.append({"path": file_path, "success": success})
# Count successes
success_count = sum(1 for r in results if r["success"])
if success_count == len(file_paths):
return web.json_response({
'success': True,
'message': f'Successfully moved {success_count} models'
})
elif success_count > 0:
return web.json_response({
'success': True,
'message': f'Moved {success_count} of {len(file_paths)} models',
'results': results
})
else:
return web.Response(text='Failed to move any models', status=500)
except Exception as e:
logger.error(f"Error moving models in bulk: {e}", exc_info=True)
return web.Response(text=str(e), status=500)

View File

@@ -385,4 +385,138 @@
.back-to-top {
bottom: 60px; /* Give some extra space from bottom on mobile */
}
}
/* Bulk Operations Styles */
.bulk-operations-panel {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateY(100px) translateX(-50%);
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-base);
padding: 12px 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: var(--z-overlay);
display: flex;
flex-direction: column;
min-width: 300px;
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
opacity: 0;
}
.bulk-operations-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
gap: 20px; /* Increase space between count and buttons */
}
#selectedCount {
font-weight: 500;
background: var(--bg-color);
padding: 6px 12px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
min-width: 80px;
text-align: center;
}
.bulk-operations-actions {
display: flex;
gap: 8px;
}
.bulk-operations-actions button {
padding: 6px 12px;
border-radius: var(--border-radius-xs);
background: var(--bg-color);
border: 1px solid var(--border-color);
color: var(--text-color);
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s ease;
}
.bulk-operations-actions button:hover {
background: var(--lora-accent);
color: white;
border-color: var(--lora-accent);
}
/* Style for selected cards */
.lora-card.selected {
box-shadow: 0 0 0 2px var(--lora-accent);
position: relative;
}
.lora-card.selected::after {
content: "✓";
position: absolute;
top: 10px;
right: 10px;
width: 24px;
height: 24px;
background: var(--lora-accent);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
z-index: 1;
}
/* Standardize button widths in controls */
.control-group button {
min-width: 100px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
/* Update bulk operations button to match others when active */
#bulkOperationsBtn.active {
background: var(--lora-accent);
color: white;
border-color: var(--lora-accent);
}
@media (max-width: 768px) {
.bulk-operations-panel {
width: calc(100% - 40px);
left: 20px;
transform: none;
border-radius: var(--border-radius-sm);
}
.bulk-operations-actions {
flex-wrap: wrap;
}
}
.bulk-operations-panel.visible {
transform: translateY(0) translateX(-50%);
opacity: 1;
}
/* Remove the page overlay */
.bulk-mode-overlay {
display: none; /* Hide the overlay completely */
}
/* Remove card scaling in bulk mode but keep the transition for other properties */
.lora-card {
transition: box-shadow 0.3s ease;
}
/* Remove the transform scale from bulk mode cards */
.bulk-mode .lora-card {
transform: none;
}

View File

@@ -60,23 +60,30 @@ export function createLoraCard(lora) {
</div>
`;
// Main card click event
// Main card click event - modified to handle bulk mode
card.addEventListener('click', () => {
const loraMeta = {
sha256: card.dataset.sha256,
file_path: card.dataset.filepath,
model_name: card.dataset.name,
file_name: card.dataset.file_name,
folder: card.dataset.folder,
modified: card.dataset.modified,
file_size: card.dataset.file_size,
from_civitai: card.dataset.from_civitai === 'true',
base_model: card.dataset.base_model,
usage_tips: card.dataset.usage_tips,
notes: card.dataset.notes,
civitai: JSON.parse(card.dataset.meta || '{}')
};
showLoraModal(loraMeta);
// Check if we're in bulk mode
if (state.bulkMode) {
// Toggle selection
toggleCardSelection(card);
} else {
// Normal behavior - show modal
const loraMeta = {
sha256: card.dataset.sha256,
file_path: card.dataset.filepath,
model_name: card.dataset.name,
file_name: card.dataset.file_name,
folder: card.dataset.folder,
modified: card.dataset.modified,
file_size: card.dataset.file_size,
from_civitai: card.dataset.from_civitai === 'true',
base_model: card.dataset.base_model,
usage_tips: card.dataset.usage_tips,
notes: card.dataset.notes,
civitai: JSON.parse(card.dataset.meta || '{}')
};
showLoraModal(loraMeta);
}
});
// Copy button click event
@@ -127,6 +134,35 @@ export function createLoraCard(lora) {
e.stopPropagation();
replacePreview(lora.file_path);
});
// Apply bulk mode styling if currently in bulk mode
if (state.bulkMode) {
const actions = card.querySelectorAll('.card-actions');
actions.forEach(actionGroup => {
actionGroup.style.display = 'none';
});
}
return card;
}
// Function to toggle selection of a card
function toggleCardSelection(card) {
card.classList.toggle('selected');
updateSelectedCount();
}
// Add a method to update card appearance based on bulk mode
export function updateCardsForBulkMode(isBulkMode) {
// Update the state
state.bulkMode = isBulkMode;
document.body.classList.toggle('bulk-mode', isBulkMode);
document.querySelectorAll('.lora-card').forEach(card => {
const actions = card.querySelectorAll('.card-actions');
actions.forEach(actionGroup => {
actionGroup.style.display = isBulkMode ? 'none' : 'flex';
});
});
}

View File

@@ -27,8 +27,14 @@ import { SettingsManager, toggleApiKeyVisibility } from './managers/SettingsMana
import { LoraContextMenu } from './components/ContextMenu.js';
import { moveManager } from './managers/MoveManager.js';
import { FilterManager } from './managers/FilterManager.js';
import { createLoraCard, updateCardsForBulkMode } from './components/LoraCard.js';
import { bulkManager } from './managers/BulkManager.js';
// Export all functions that need global access
// Add bulk mode to state
state.bulkMode = false;
state.selectedLoras = new Set();
// Export functions to global window object
window.loadMoreLoras = loadMoreLoras;
window.fetchCivitai = fetchCivitai;
window.deleteModel = deleteModel;
@@ -51,6 +57,14 @@ window.moveManager = moveManager;
window.toggleShowcase = toggleShowcase;
window.scrollToTop = scrollToTop;
// Export bulk manager methods to window
window.toggleBulkMode = () => bulkManager.toggleBulkMode();
window.clearSelection = () => bulkManager.clearSelection();
window.toggleCardSelection = (card) => bulkManager.toggleCardSelection(card);
window.copyAllLorasSyntax = () => bulkManager.copyAllLorasSyntax();
window.updateSelectedCount = () => bulkManager.updateSelectedCount();
window.bulkManager = bulkManager;
// Initialize everything when DOM is ready
document.addEventListener('DOMContentLoaded', async () => {
state.loadingManager = new LoadingManager();
@@ -73,6 +87,12 @@ document.addEventListener('DOMContentLoaded', async () => {
initBackToTop();
window.searchManager = new SearchManager();
new LoraContextMenu();
// Initialize cards for current bulk mode state (should be false initially)
updateCardsForBulkMode(state.bulkMode);
// Initialize the bulk manager
bulkManager.initialize();
});
// Initialize event listeners

View File

@@ -0,0 +1,106 @@
import { state } from '../state/index.js';
import { showToast } from '../utils/uiHelpers.js';
import { updateCardsForBulkMode } from '../components/LoraCard.js';
export class BulkManager {
constructor() {
this.bulkBtn = document.getElementById('bulkOperationsBtn');
this.bulkPanel = document.getElementById('bulkOperationsPanel');
// Initialize selected loras set in state if not already there
if (!state.selectedLoras) {
state.selectedLoras = new Set();
}
}
initialize() {
// Add event listeners if needed
// (Already handled via onclick attributes in HTML, but could be moved here)
}
toggleBulkMode() {
// Toggle the state
state.bulkMode = !state.bulkMode;
// Update UI
this.bulkBtn.classList.toggle('active', state.bulkMode);
// Important: Remove the hidden class when entering bulk mode
if (state.bulkMode) {
this.bulkPanel.classList.remove('hidden');
// Use setTimeout to ensure the DOM updates before adding visible class
// This helps with the transition animation
setTimeout(() => {
this.bulkPanel.classList.add('visible');
}, 10);
} else {
this.bulkPanel.classList.remove('visible');
// Add hidden class back after transition completes
setTimeout(() => {
this.bulkPanel.classList.add('hidden');
}, 400); // Match this with the transition duration in CSS
}
// Update all cards
updateCardsForBulkMode(state.bulkMode);
// Clear selection if exiting bulk mode
if (!state.bulkMode) {
this.clearSelection();
}
}
clearSelection() {
document.querySelectorAll('.lora-card.selected').forEach(card => {
card.classList.remove('selected');
});
state.selectedLoras.clear();
this.updateSelectedCount();
}
updateSelectedCount() {
const selectedCards = document.querySelectorAll('.lora-card.selected');
const countElement = document.getElementById('selectedCount');
if (countElement) {
countElement.textContent = `${selectedCards.length} selected`;
}
// Update state with selected loras
state.selectedLoras.clear();
selectedCards.forEach(card => {
state.selectedLoras.add(card.dataset.filepath);
});
}
toggleCardSelection(card) {
card.classList.toggle('selected');
this.updateSelectedCount();
}
async copyAllLorasSyntax() {
const selectedCards = document.querySelectorAll('.lora-card.selected');
if (selectedCards.length === 0) {
showToast('No LoRAs selected', 'warning');
return;
}
const loraSyntaxes = [];
selectedCards.forEach(card => {
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
const strength = usageTips.strength || 1;
loraSyntaxes.push(`<lora:${card.dataset.file_name}:${strength}>`);
});
try {
await navigator.clipboard.writeText(loraSyntaxes.join(', '));
showToast(`Copied ${selectedCards.length} LoRA syntaxes to clipboard`, 'success');
} catch (err) {
console.error('Copy failed:', err);
showToast('Copy failed', 'error');
}
}
}
// Create a singleton instance
export const bulkManager = new BulkManager();

View File

@@ -5,11 +5,13 @@ import { modalManager } from './ModalManager.js';
class MoveManager {
constructor() {
this.currentFilePath = null;
this.bulkFilePaths = null;
this.modal = document.getElementById('moveModal');
this.loraRootSelect = document.getElementById('moveLoraRoot');
this.folderBrowser = document.getElementById('moveFolderBrowser');
this.newFolderInput = document.getElementById('moveNewFolder');
this.pathDisplay = document.getElementById('moveTargetPathDisplay');
this.modalTitle = document.getElementById('moveModalTitle');
this.initializeEventListeners();
}
@@ -43,7 +45,24 @@ class MoveManager {
}
async showMoveModal(filePath) {
this.currentFilePath = filePath;
// Reset state
this.currentFilePath = null;
this.bulkFilePaths = null;
// Handle bulk mode
if (filePath === 'bulk') {
const selectedPaths = Array.from(state.selectedLoras);
if (selectedPaths.length === 0) {
showToast('No LoRAs selected', 'warning');
return;
}
this.bulkFilePaths = selectedPaths;
this.modalTitle.textContent = `Move ${selectedPaths.length} LoRAs`;
} else {
// Single file mode
this.currentFilePath = filePath;
this.modalTitle.textContent = "Move Model";
}
// 清除之前的选择
this.folderBrowser.querySelectorAll('.folder-item').forEach(item => {
@@ -105,36 +124,81 @@ class MoveManager {
targetPath = `${targetPath}/${newFolder}`;
}
try {
if (this.bulkFilePaths) {
// Bulk move mode
await this.moveBulkModels(this.bulkFilePaths, targetPath);
} else {
// Single move mode
await this.moveSingleModel(this.currentFilePath, targetPath);
}
modalManager.closeModal('moveModal');
await resetAndReload(true);
// If we were in bulk mode, exit it after successful move
if (this.bulkFilePaths && state.bulkMode) {
toggleBulkMode();
}
} catch (error) {
console.error('Error moving model(s):', error);
showToast('Failed to move model(s): ' + error.message, 'error');
}
}
async moveSingleModel(filePath, targetPath) {
// show toast if current path is same as target path
if (this.currentFilePath.substring(0, this.currentFilePath.lastIndexOf('/')) === targetPath) {
if (filePath.substring(0, filePath.lastIndexOf('/')) === targetPath) {
showToast('Model is already in the selected folder', 'info');
return;
}
try {
const response = await fetch('/api/move_model', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_path: this.currentFilePath,
target_path: targetPath
})
});
const response = await fetch('/api/move_model', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_path: filePath,
target_path: targetPath
})
});
if (!response.ok) {
throw new Error('Failed to move model');
}
showToast('Model moved successfully', 'success');
modalManager.closeModal('moveModal');
await resetAndReload(true);
} catch (error) {
console.error('Error moving model:', error);
showToast('Failed to move model: ' + error.message, 'error');
if (!response.ok) {
throw new Error('Failed to move model');
}
showToast('Model moved successfully', 'success');
}
async moveBulkModels(filePaths, targetPath) {
// Filter out models already in the target path
const movedPaths = filePaths.filter(path => {
return path.substring(0, path.lastIndexOf('/')) !== targetPath;
});
if (movedPaths.length === 0) {
showToast('All selected models are already in the target folder', 'info');
return;
}
const response = await fetch('/api/move_models_bulk', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_paths: movedPaths,
target_path: targetPath
})
});
if (!response.ok) {
throw new Error('Failed to move models');
}
showToast(`Successfully moved ${movedPaths.length} models`, 'success');
}
}

View File

@@ -10,5 +10,6 @@ export const state = {
searchManager: null,
filters: {
baseModel: []
}
},
bulkMode: false
};

View File

@@ -28,6 +28,11 @@
<i class="fas fa-cloud-download-alt"></i> Download
</button>
</div>
<div class="control-group">
<button id="bulkOperationsBtn" onclick="bulkManager.toggleBulkMode()" title="Bulk Operations">
<i class="fas fa-th-large"></i> Bulk
</button>
</div>
<div class="search-container">
<input type="text" id="searchInput" placeholder="Search models..." />
<!-- 清空按钮将由JavaScript动态添加到这里 -->
@@ -62,4 +67,22 @@
Clear All Filters
</button>
</div>
</div>
<!-- Add bulk operations panel (initially hidden) -->
<div id="bulkOperationsPanel" class="bulk-operations-panel hidden">
<div class="bulk-operations-header">
<span id="selectedCount">0 selected</span>
<div class="bulk-operations-actions">
<button onclick="bulkManager.copyAllLorasSyntax()" title="Copy all selected LoRAs syntax">
<i class="fas fa-copy"></i> Copy All
</button>
<button onclick="moveManager.showMoveModal('bulk')" title="Move selected LoRAs to folder">
<i class="fas fa-folder-open"></i> Move All
</button>
<button onclick="bulkManager.clearSelection()" title="Clear selection">
<i class="fas fa-times"></i> Clear
</button>
</div>
</div>
</div>

View File

@@ -86,8 +86,10 @@
<!-- Move Model Modal -->
<div id="moveModal" class="modal">
<div class="modal-content">
<button class="close" onclick="modalManager.closeModal('moveModal')">&times;</button>
<h2>Move Model</h2>
<div class="modal-header">
<h2 id="moveModalTitle">Move Model</h2>
<span class="close" onclick="modalManager.closeModal('moveModal')">&times;</span>
</div>
<div class="location-selection">
<div class="path-preview">
<label>Target Location Preview:</label>

View File

@@ -88,9 +88,13 @@
<div class="card-grid" id="loraGrid" style="height: calc(100vh - [header-height]px); overflow-y: auto;">
<!-- Cards will be dynamically inserted here -->
</div>
<!-- Bulk operations panel will be inserted here by JavaScript -->
{% endif %}
</div>
<!-- Add after the container div -->
<div class="bulk-mode-overlay"></div>
<script type="module" src="/loras_static/js/main.js"></script>
{% if is_initializing %}
<script>