feat(bulk-tags): add bulk tag management modal and context menu integration

This commit is contained in:
Will Miao
2025-09-04 22:08:55 +08:00
parent a5a9f7ed83
commit 4eb67cf6da
7 changed files with 285 additions and 2 deletions

View File

@@ -26,6 +26,7 @@ export class BulkContextMenu extends BaseContextMenu {
if (!config) return;
// Update button visibility based on model type
const addTagsItem = this.menu.querySelector('[data-action="add-tags"]');
const sendToWorkflowItem = this.menu.querySelector('[data-action="send-to-workflow"]');
const copyAllItem = this.menu.querySelector('[data-action="copy-all"]');
const refreshAllItem = this.menu.querySelector('[data-action="refresh-all"]');
@@ -47,6 +48,9 @@ export class BulkContextMenu extends BaseContextMenu {
if (deleteAllItem) {
deleteAllItem.style.display = config.deleteAll ? 'flex' : 'none';
}
if (addTagsItem) {
addTagsItem.style.display = config.addTags ? 'flex' : 'none';
}
}
updateSelectedCountHeader() {
@@ -64,6 +68,9 @@ export class BulkContextMenu extends BaseContextMenu {
handleMenuAction(action, menuItem) {
switch (action) {
case 'add-tags':
bulkManager.showBulkAddTagsModal();
break;
case 'send-to-workflow':
bulkManager.sendAllModelsToWorkflow();
break;

View File

@@ -139,7 +139,7 @@ export function setupTagEditMode() {
// ...existing helper functions...
/**
* Save tags - 支持LoRA和Checkpoint
* Save tags
*/
async function saveTags() {
const editBtn = document.querySelector('.edit-tags-btn');

View File

@@ -14,6 +14,7 @@ export class BulkManager {
// Model type specific action configurations
this.actionConfig = {
[MODEL_TYPES.LORA]: {
addTags: true,
sendToWorkflow: true,
copyAll: true,
refreshAll: true,
@@ -21,6 +22,7 @@ export class BulkManager {
deleteAll: true
},
[MODEL_TYPES.EMBEDDING]: {
addTags: true,
sendToWorkflow: false,
copyAll: false,
refreshAll: true,
@@ -28,6 +30,7 @@ export class BulkManager {
deleteAll: true
},
[MODEL_TYPES.CHECKPOINT]: {
addTags: true,
sendToWorkflow: false,
copyAll: false,
refreshAll: true,
@@ -406,6 +409,225 @@ export class BulkManager {
showToast('toast.models.refreshMetadataFailed', {}, 'error');
}
}
showBulkAddTagsModal() {
if (state.selectedModels.size === 0) {
showToast('toast.models.noModelsSelected', {}, 'warning');
return;
}
const countElement = document.getElementById('bulkAddTagsCount');
if (countElement) {
countElement.textContent = state.selectedModels.size;
}
// Clear any existing tags in the modal
const tagsContainer = document.getElementById('bulkTagsItems');
if (tagsContainer) {
tagsContainer.innerHTML = '';
}
modalManager.showModal('bulkAddTagsModal', null, null, () => {
// Cleanup when modal is closed
this.cleanupBulkAddTagsModal();
});
// Initialize the bulk tags editing interface
this.initializeBulkTagsInterface();
}
initializeBulkTagsInterface() {
// Import preset tags from ModelTags.js
const PRESET_TAGS = [
'character', 'style', 'concept', 'clothing',
'poses', 'background', 'vehicle', 'buildings',
'objects', 'animal'
];
// Setup tag input behavior
const tagInput = document.querySelector('.bulk-metadata-input');
if (tagInput) {
tagInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.addBulkTag(e.target.value.trim());
e.target.value = '';
}
});
}
// Create suggestions dropdown
const tagForm = document.querySelector('#bulkAddTagsModal .metadata-add-form');
if (tagForm) {
const suggestionsDropdown = this.createBulkSuggestionsDropdown(PRESET_TAGS);
tagForm.appendChild(suggestionsDropdown);
}
// Setup save button
const saveBtn = document.querySelector('.bulk-save-tags-btn');
if (saveBtn) {
saveBtn.addEventListener('click', () => {
this.saveBulkTags();
});
}
}
createBulkSuggestionsDropdown(presetTags) {
const dropdown = document.createElement('div');
dropdown.className = 'metadata-suggestions-dropdown';
const header = document.createElement('div');
header.className = 'metadata-suggestions-header';
header.innerHTML = `
<span>Suggested Tags</span>
<small>Click to add</small>
`;
dropdown.appendChild(header);
const container = document.createElement('div');
container.className = 'metadata-suggestions-container';
presetTags.forEach(tag => {
const item = document.createElement('div');
item.className = 'metadata-suggestion-item';
item.title = tag;
item.innerHTML = `<span class="metadata-suggestion-text">${tag}</span>`;
item.addEventListener('click', () => {
this.addBulkTag(tag);
const input = document.querySelector('.bulk-metadata-input');
if (input) {
input.value = tag;
input.focus();
}
});
container.appendChild(item);
});
dropdown.appendChild(container);
return dropdown;
}
addBulkTag(tag) {
tag = tag.trim().toLowerCase();
if (!tag) return;
const tagsContainer = document.getElementById('bulkTagsItems');
if (!tagsContainer) return;
// Validation: Check length
if (tag.length > 30) {
showToast('modelTags.validation.maxLength', {}, 'error');
return;
}
// Validation: Check total number
const currentTags = tagsContainer.querySelectorAll('.metadata-item');
if (currentTags.length >= 30) {
showToast('modelTags.validation.maxCount', {}, 'error');
return;
}
// Validation: Check for duplicates
const existingTags = Array.from(currentTags).map(tagEl => tagEl.dataset.tag);
if (existingTags.includes(tag)) {
showToast('modelTags.validation.duplicate', {}, 'error');
return;
}
// Create new tag
const newTag = document.createElement('div');
newTag.className = 'metadata-item';
newTag.dataset.tag = tag;
newTag.innerHTML = `
<span class="metadata-item-content">${tag}</span>
<button class="metadata-delete-btn">
<i class="fas fa-times"></i>
</button>
`;
// Add delete button event listener
const deleteBtn = newTag.querySelector('.metadata-delete-btn');
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
newTag.remove();
});
tagsContainer.appendChild(newTag);
}
async saveBulkTags() {
const tagElements = document.querySelectorAll('#bulkTagsItems .metadata-item');
const tags = Array.from(tagElements).map(tag => tag.dataset.tag);
if (tags.length === 0) {
showToast('toast.models.noTagsToAdd', {}, 'warning');
return;
}
if (state.selectedModels.size === 0) {
showToast('toast.models.noModelsSelected', {}, 'warning');
return;
}
try {
const apiClient = getModelApiClient();
const filePaths = Array.from(state.selectedModels);
let successCount = 0;
let failCount = 0;
// Add tags to each selected model
for (const filePath of filePaths) {
try {
await apiClient.addTags(filePath, { tags: tags });
successCount++;
} catch (error) {
console.error(`Failed to add tags to ${filePath}:`, error);
failCount++;
}
}
modalManager.closeModal('bulkAddTagsModal');
if (successCount > 0) {
const currentConfig = MODEL_CONFIG[state.currentPageType];
showToast('toast.models.tagsAddedSuccessfully', {
count: successCount,
tagCount: tags.length,
type: currentConfig.displayName.toLowerCase()
}, 'success');
}
if (failCount > 0) {
showToast('toast.models.tagsAddFailed', { count: failCount }, 'warning');
}
} catch (error) {
console.error('Error during bulk tag addition:', error);
showToast('toast.models.bulkTagsAddFailed', {}, 'error');
}
}
cleanupBulkAddTagsModal() {
// Clear tags container
const tagsContainer = document.getElementById('bulkTagsItems');
if (tagsContainer) {
tagsContainer.innerHTML = '';
}
// Clear input
const input = document.querySelector('.bulk-metadata-input');
if (input) {
input.value = '';
}
// Remove event listeners (they will be re-added when modal opens again)
const saveBtn = document.querySelector('.bulk-save-tags-btn');
if (saveBtn) {
saveBtn.replaceWith(saveBtn.cloneNode(true));
}
}
}
export const bulkManager = new BulkManager();

View File

@@ -234,6 +234,19 @@ export class ModalManager {
});
}
// Add bulkAddTagsModal registration
const bulkAddTagsModal = document.getElementById('bulkAddTagsModal');
if (bulkAddTagsModal) {
this.registerModal('bulkAddTagsModal', {
element: bulkAddTagsModal,
onClose: () => {
this.getModal('bulkAddTagsModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
document.addEventListener('keydown', this.boundHandleEscape);
this.initialized = true;
}

View File

@@ -50,6 +50,9 @@
<span>{{ t('loras.bulkOperations.selected', {'count': 0}) }}</span>
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item" data-action="add-tags">
<i class="fas fa-tags"></i> <span>{{ t('loras.bulkOperations.addTags') }}</span>
</div>
<div class="context-menu-item" data-action="send-to-workflow">
<i class="fas fa-paper-plane"></i> <span>{{ t('loras.bulkOperations.sendToWorkflow') }}</span>
</div>

View File

@@ -9,4 +9,5 @@
{% include 'components/modals/relink_civitai_modal.html' %}
{% include 'components/modals/example_access_modal.html' %}
{% include 'components/modals/download_modal.html' %}
{% include 'components/modals/move_modal.html' %}
{% include 'components/modals/move_modal.html' %}
{% include 'components/modals/bulk_add_tags_modal.html' %}

View File

@@ -0,0 +1,37 @@
<div id="bulkAddTagsModal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3><i class="fas fa-tags"></i> {{ t('modals.bulkAddTags.title') }}</h3>
<button class="modal-close" onclick="modalManager.closeModal('bulkAddTagsModal')">
<i class="fas fa-times"></i>
</button>
</div>
<div class="modal-body">
<div class="bulk-add-tags-info">
<p>{{ t('modals.bulkAddTags.description') }} <span id="bulkAddTagsCount">0</span> {{ t('modals.bulkAddTags.models') }}</p>
</div>
<div class="model-tags-container bulk-tags-container edit-mode">
<div class="metadata-edit-container" style="display: block;">
<div class="metadata-edit-content">
<div class="metadata-edit-header">
<label>{{ t('modals.bulkAddTags.tagsToAdd') }}</label>
</div>
<div class="metadata-items" id="bulkTagsItems">
<!-- Tags will be added here dynamically -->
</div>
<div class="metadata-edit-controls">
<button class="save-tags-btn bulk-save-tags-btn" title="{{ t('modals.bulkAddTags.saveChanges') }}">
<i class="fas fa-save"></i> {{ t('common.actions.save') }}
</button>
</div>
<div class="metadata-add-form">
<input type="text" class="metadata-input bulk-metadata-input" placeholder="{{ t('modals.bulkAddTags.placeholder') }}">
</div>
<!-- Suggestions dropdown will be added here -->
</div>
</div>
</div>
</div>
</div>
</div>