diff --git a/src/components/PromptQuickAdd.vue b/src/components/PromptQuickAdd.vue index 5780eb2..bc6a757 100644 --- a/src/components/PromptQuickAdd.vue +++ b/src/components/PromptQuickAdd.vue @@ -13,21 +13,143 @@ const store = usePromptStore(); const categories = computed(() => store.categories); const currentCategory = computed(() => store.currentCategory); const currentGroup = computed(() => store.currentGroup); -const filteredTags = computed(() => store.filteredTags); const selectedLang = computed(() => store.selectedLang); const PAGE_SIZE = 50; const QUICK_ADD_STATE_KEY = 'prompt-quick-add-view-state'; +const QUICK_ADD_RECENT_KEY = 'prompt-quick-add-recent-tags'; +const QUICK_ADD_FAVORITE_KEY = 'prompt-quick-add-favorite-tags'; const visibleCount = ref(PAGE_SIZE); const tagsContainer = ref(null); const draggedTagKey = ref(null); const isRestoringState = ref(false); +const localSearch = ref(''); +const recentKeys = ref([]); +const favoriteKeys = ref([]); -const visibleTags = computed(() => { - return filteredTags.value.slice(0, visibleCount.value); +type QuickAddItem = { + key: string; + translation: string; + categoryName?: string; + groupName?: string; +}; + +function normalizeQuery(value: string) { + return value.trim().toLowerCase().replace(/_/g, ' '); +} + +function matchesQuery(tag: PromptTag, query: string, queryNorm: string) { + const translation = tag.translation?.[selectedLang.value] ?? tag.key; + const keyLower = tag.key.toLowerCase(); + const keyNorm = keyLower.replace(/_/g, ' '); + const transLower = translation.toLowerCase(); + const transNorm = transLower.replace(/_/g, ' '); + return ( + keyLower.includes(query) || + keyNorm.includes(queryNorm) || + transLower.includes(query) || + transNorm.includes(queryNorm) + ); +} + +const currentGroupItems = computed(() => { + const group = currentGroup.value; + if (!group) return []; + return group.tags.map(tag => ({ + key: tag.key, + translation: tag.translation?.[selectedLang.value] ?? tag.key, + categoryName: currentCategory.value?.name, + groupName: group.name, + })); }); -watch(() => filteredTags.value, () => { +const searchResults = computed(() => { + const query = localSearch.value.trim().toLowerCase(); + const queryNorm = normalizeQuery(localSearch.value); + if (!query) return []; + const deduped = new Map(); + for (const category of categories.value) { + for (const group of category.groups) { + for (const tag of group.tags) { + if (!matchesQuery(tag, query, queryNorm)) continue; + if (deduped.has(tag.key)) continue; + deduped.set(tag.key, { + key: tag.key, + translation: tag.translation?.[selectedLang.value] ?? tag.key, + categoryName: category.name, + groupName: group.name, + }); + } + } + } + return Array.from(deduped.values()).sort((a, b) => { + const af = favoriteKeys.value.includes(a.key) ? 0 : 1; + const bf = favoriteKeys.value.includes(b.key) ? 0 : 1; + if (af !== bf) return af - bf; + return a.translation.localeCompare(b.translation, 'zh-CN'); + }); +}); + +const allVisibleItems = computed(() => { + return localSearch.value.trim() ? searchResults.value : currentGroupItems.value; +}); + +const visibleItems = computed(() => { + return allVisibleItems.value.slice(0, visibleCount.value); +}); + +const recentItems = computed(() => { + const tagMap = new Map(); + for (const category of categories.value) { + for (const group of category.groups) { + for (const tag of group.tags) { + if (!tagMap.has(tag.key)) { + tagMap.set(tag.key, { + key: tag.key, + translation: tag.translation?.[selectedLang.value] ?? tag.key, + categoryName: category.name, + groupName: group.name, + }); + } + } + } + } + return recentKeys.value + .map(key => tagMap.get(key)) + .filter((item): item is QuickAddItem => !!item); +}); + +const favoriteItems = computed(() => { + const tagMap = new Map(); + for (const category of categories.value) { + for (const group of category.groups) { + for (const tag of group.tags) { + if (!tagMap.has(tag.key)) { + tagMap.set(tag.key, { + key: tag.key, + translation: tag.translation?.[selectedLang.value] ?? tag.key, + categoryName: category.name, + groupName: group.name, + }); + } + } + } + } + return favoriteKeys.value + .map(key => tagMap.get(key)) + .filter((item): item is QuickAddItem => !!item); +}); + +const panelSummary = computed(() => { + if (localSearch.value.trim()) { + return `全局搜索结果 ${searchResults.value.length} 个`; + } + const category = currentCategory.value?.name ?? '未选择分类'; + const group = currentGroup.value?.name ?? '未选择分组'; + return `${category} / ${group} · ${currentGroupItems.value.length} 个`; +}); + +watch(() => [allVisibleItems.value.length, localSearch.value], () => { if (isRestoringState.value) return; visibleCount.value = PAGE_SIZE; if (tagsContainer.value) { @@ -41,7 +163,7 @@ function onScroll() { if (!el) return; // Simple infinite scroll trigger if (el.scrollTop + el.clientHeight >= el.scrollHeight - 100) { - if (visibleCount.value < filteredTags.value.length) { + if (visibleCount.value < allVisibleItems.value.length) { visibleCount.value += PAGE_SIZE; } } @@ -61,6 +183,7 @@ function selectGroup(index: number) { function onTagClick(tag: PromptTag) { if (draggedTagKey.value === tag.key) return; emit('add-tag', tag.key); + rememberRecentTag(tag.key); } function onTagDragStart(tag: PromptTag, event: DragEvent) { @@ -79,8 +202,68 @@ function onTagDragEnd() { }, 0); } -function displayTrans(tag: PromptTag) { - return tag.translation?.[selectedLang.value] ?? tag.key; +function onQuickAddItemClick(item: QuickAddItem) { + onTagClick({ key: item.key, translation: { [selectedLang.value]: item.translation } }); +} + +function onQuickAddItemDragStart(item: QuickAddItem, event: DragEvent) { + onTagDragStart({ key: item.key, translation: { [selectedLang.value]: item.translation } }, event); +} + +function rememberRecentTag(tagKey: string) { + recentKeys.value = [tagKey, ...recentKeys.value.filter(key => key !== tagKey)].slice(0, 12); + persistRecentTags(); +} + +function isFavorite(tagKey: string) { + return favoriteKeys.value.includes(tagKey); +} + +function toggleFavorite(tagKey: string) { + if (isFavorite(tagKey)) { + favoriteKeys.value = favoriteKeys.value.filter(key => key !== tagKey); + } else { + favoriteKeys.value = [tagKey, ...favoriteKeys.value].slice(0, 30); + } + persistFavoriteTags(); +} + +function clearSearch() { + localSearch.value = ''; +} + +function persistRecentTags() { + window.localStorage.setItem(QUICK_ADD_RECENT_KEY, JSON.stringify(recentKeys.value)); +} + +function persistFavoriteTags() { + window.localStorage.setItem(QUICK_ADD_FAVORITE_KEY, JSON.stringify(favoriteKeys.value)); +} + +function restoreRecentTags() { + const raw = window.localStorage.getItem(QUICK_ADD_RECENT_KEY); + if (!raw) return; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + recentKeys.value = parsed.filter((value): value is string => typeof value === 'string').slice(0, 12); + } + } catch { + recentKeys.value = []; + } +} + +function restoreFavoriteTags() { + const raw = window.localStorage.getItem(QUICK_ADD_FAVORITE_KEY); + if (!raw) return; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + favoriteKeys.value = parsed.filter((value): value is string => typeof value === 'string').slice(0, 30); + } + } catch { + favoriteKeys.value = []; + } } function persistQuickAddState() { @@ -89,6 +272,7 @@ function persistQuickAddState() { groupIndex: store.selectedGroupIndex, visibleCount: visibleCount.value, scrollTop: tagsContainer.value?.scrollTop ?? 0, + search: localSearch.value, }; window.sessionStorage.setItem(QUICK_ADD_STATE_KEY, JSON.stringify(payload)); } @@ -102,6 +286,7 @@ async function restoreQuickAddState() { groupIndex?: number; visibleCount?: number; scrollTop?: number; + search?: string; }; isRestoringState.value = true; const categoryCount = categories.value.length; @@ -110,6 +295,7 @@ async function restoreQuickAddState() { const groupCount = currentCategory.value?.groups.length ?? 0; const nextGroupIndex = Math.min(Math.max(state.groupIndex ?? 0, 0), Math.max(groupCount - 1, 0)); store.selectGroup(nextGroupIndex); + localSearch.value = state.search ?? ''; visibleCount.value = Math.max(PAGE_SIZE, state.visibleCount ?? PAGE_SIZE); await nextTick(); if (tagsContainer.value) { @@ -133,6 +319,8 @@ watch( ); onMounted(() => { + restoreFavoriteTags(); + restoreRecentTags(); restoreQuickAddState(); }); @@ -143,6 +331,53 @@ onUnmounted(() => {