feat(filter): add tag logic toggle (OR/AND) for include tags filtering

Add a segmented toggle in the Filter Panel to switch between 'Any' (OR)
and 'All' (AND) logic when filtering by multiple include tags.

Changes:
- Backend: Add tag_logic field to FilterCriteria and ModelFilterSet
- Backend: Parse tag_logic parameter in model handlers
- Frontend: Add segmented toggle UI in filter panel header
- Frontend: Add interaction logic and state management for tag logic
- Add translations for all supported languages
- Add comprehensive tests for the new feature

Closes #802
This commit is contained in:
Will Miao
2026-02-05 22:35:18 +08:00
parent 895d13dc96
commit fa3625ff72
20 changed files with 927 additions and 15 deletions

View File

@@ -924,6 +924,11 @@ export class BaseModelApiClient {
params.append('model_type', type);
});
}
// Add tag logic parameter (any = OR, all = AND)
if (pageState.filters.tagLogic) {
params.append('tag_logic', pageState.filters.tagLogic);
}
}
this._addModelSpecificParams(params, pageState);

View File

@@ -63,6 +63,9 @@ export class FilterManager {
this.initializeLicenseFilters();
}
// Initialize tag logic toggle
this.initializeTagLogicToggle();
// Add click handler for filter button
if (this.filterButton) {
this.filterButton.addEventListener('click', () => {
@@ -84,6 +87,45 @@ export class FilterManager {
this.loadFiltersFromStorage();
}
initializeTagLogicToggle() {
const toggleContainer = document.getElementById('tagLogicToggle');
if (!toggleContainer) return;
const options = toggleContainer.querySelectorAll('.tag-logic-option');
options.forEach(option => {
option.addEventListener('click', async () => {
const value = option.dataset.value;
if (this.filters.tagLogic === value) return;
this.filters.tagLogic = value;
this.updateTagLogicToggleUI();
// Auto-apply filter when logic changes
await this.applyFilters(false);
});
});
// Set initial state
this.updateTagLogicToggleUI();
}
updateTagLogicToggleUI() {
const toggleContainer = document.getElementById('tagLogicToggle');
if (!toggleContainer) return;
const options = toggleContainer.querySelectorAll('.tag-logic-option');
const currentLogic = this.filters.tagLogic || 'any';
options.forEach(option => {
if (option.dataset.value === currentLogic) {
option.classList.add('active');
} else {
option.classList.remove('active');
}
});
}
async loadTopTags() {
try {
// Show loading state
@@ -573,9 +615,13 @@ export class FilterManager {
baseModel: [],
tags: {},
license: {},
modelTypes: []
modelTypes: [],
tagLogic: 'any'
});
// Update tag logic toggle UI
this.updateTagLogicToggleUI();
// Update state
const pageState = getCurrentPageState();
pageState.filters = this.cloneFilters();
@@ -620,6 +666,7 @@ export class FilterManager {
pageState.filters = this.cloneFilters();
this.updateTagSelections();
this.updateTagLogicToggleUI();
this.updateActiveFiltersCount();
if (this.hasActiveFilters()) {
@@ -655,7 +702,8 @@ export class FilterManager {
baseModel: Array.isArray(source.baseModel) ? [...source.baseModel] : [],
tags: this.normalizeTagFilters(source.tags),
license: this.shouldShowLicenseFilters() ? this.normalizeLicenseFilters(source.license) : {},
modelTypes: this.normalizeModelTypeFilters(source.modelTypes)
modelTypes: this.normalizeModelTypeFilters(source.modelTypes),
tagLogic: source.tagLogic || 'any'
};
}
@@ -737,7 +785,8 @@ export class FilterManager {
baseModel: [...(this.filters.baseModel || [])],
tags: { ...(this.filters.tags || {}) },
license: { ...(this.filters.license || {}) },
modelTypes: [...(this.filters.modelTypes || [])]
modelTypes: [...(this.filters.modelTypes || [])],
tagLogic: this.filters.tagLogic || 'any'
};
}