增加快速词搜索框和收藏功能

This commit is contained in:
2026-05-09 10:43:33 +08:00
parent 5c1b6a86ca
commit 01ca52cfc7
+446 -15
View File
@@ -13,21 +13,143 @@ const store = usePromptStore();
const categories = computed(() => store.categories); const categories = computed(() => store.categories);
const currentCategory = computed(() => store.currentCategory); const currentCategory = computed(() => store.currentCategory);
const currentGroup = computed(() => store.currentGroup); const currentGroup = computed(() => store.currentGroup);
const filteredTags = computed(() => store.filteredTags);
const selectedLang = computed(() => store.selectedLang); const selectedLang = computed(() => store.selectedLang);
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
const QUICK_ADD_STATE_KEY = 'prompt-quick-add-view-state'; 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 visibleCount = ref(PAGE_SIZE);
const tagsContainer = ref<HTMLElement | null>(null); const tagsContainer = ref<HTMLElement | null>(null);
const draggedTagKey = ref<string | null>(null); const draggedTagKey = ref<string | null>(null);
const isRestoringState = ref(false); const isRestoringState = ref(false);
const localSearch = ref('');
const recentKeys = ref<string[]>([]);
const favoriteKeys = ref<string[]>([]);
const visibleTags = computed(() => { type QuickAddItem = {
return filteredTags.value.slice(0, visibleCount.value); 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<QuickAddItem[]>(() => {
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<QuickAddItem[]>(() => {
const query = localSearch.value.trim().toLowerCase();
const queryNorm = normalizeQuery(localSearch.value);
if (!query) return [];
const deduped = new Map<string, QuickAddItem>();
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<QuickAddItem[]>(() => {
return localSearch.value.trim() ? searchResults.value : currentGroupItems.value;
});
const visibleItems = computed(() => {
return allVisibleItems.value.slice(0, visibleCount.value);
});
const recentItems = computed<QuickAddItem[]>(() => {
const tagMap = new Map<string, QuickAddItem>();
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<QuickAddItem[]>(() => {
const tagMap = new Map<string, QuickAddItem>();
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; if (isRestoringState.value) return;
visibleCount.value = PAGE_SIZE; visibleCount.value = PAGE_SIZE;
if (tagsContainer.value) { if (tagsContainer.value) {
@@ -41,7 +163,7 @@ function onScroll() {
if (!el) return; if (!el) return;
// Simple infinite scroll trigger // Simple infinite scroll trigger
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 100) { if (el.scrollTop + el.clientHeight >= el.scrollHeight - 100) {
if (visibleCount.value < filteredTags.value.length) { if (visibleCount.value < allVisibleItems.value.length) {
visibleCount.value += PAGE_SIZE; visibleCount.value += PAGE_SIZE;
} }
} }
@@ -61,6 +183,7 @@ function selectGroup(index: number) {
function onTagClick(tag: PromptTag) { function onTagClick(tag: PromptTag) {
if (draggedTagKey.value === tag.key) return; if (draggedTagKey.value === tag.key) return;
emit('add-tag', tag.key); emit('add-tag', tag.key);
rememberRecentTag(tag.key);
} }
function onTagDragStart(tag: PromptTag, event: DragEvent) { function onTagDragStart(tag: PromptTag, event: DragEvent) {
@@ -79,8 +202,68 @@ function onTagDragEnd() {
}, 0); }, 0);
} }
function displayTrans(tag: PromptTag) { function onQuickAddItemClick(item: QuickAddItem) {
return tag.translation?.[selectedLang.value] ?? tag.key; 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() { function persistQuickAddState() {
@@ -89,6 +272,7 @@ function persistQuickAddState() {
groupIndex: store.selectedGroupIndex, groupIndex: store.selectedGroupIndex,
visibleCount: visibleCount.value, visibleCount: visibleCount.value,
scrollTop: tagsContainer.value?.scrollTop ?? 0, scrollTop: tagsContainer.value?.scrollTop ?? 0,
search: localSearch.value,
}; };
window.sessionStorage.setItem(QUICK_ADD_STATE_KEY, JSON.stringify(payload)); window.sessionStorage.setItem(QUICK_ADD_STATE_KEY, JSON.stringify(payload));
} }
@@ -102,6 +286,7 @@ async function restoreQuickAddState() {
groupIndex?: number; groupIndex?: number;
visibleCount?: number; visibleCount?: number;
scrollTop?: number; scrollTop?: number;
search?: string;
}; };
isRestoringState.value = true; isRestoringState.value = true;
const categoryCount = categories.value.length; const categoryCount = categories.value.length;
@@ -110,6 +295,7 @@ async function restoreQuickAddState() {
const groupCount = currentCategory.value?.groups.length ?? 0; const groupCount = currentCategory.value?.groups.length ?? 0;
const nextGroupIndex = Math.min(Math.max(state.groupIndex ?? 0, 0), Math.max(groupCount - 1, 0)); const nextGroupIndex = Math.min(Math.max(state.groupIndex ?? 0, 0), Math.max(groupCount - 1, 0));
store.selectGroup(nextGroupIndex); store.selectGroup(nextGroupIndex);
localSearch.value = state.search ?? '';
visibleCount.value = Math.max(PAGE_SIZE, state.visibleCount ?? PAGE_SIZE); visibleCount.value = Math.max(PAGE_SIZE, state.visibleCount ?? PAGE_SIZE);
await nextTick(); await nextTick();
if (tagsContainer.value) { if (tagsContainer.value) {
@@ -133,6 +319,8 @@ watch(
); );
onMounted(() => { onMounted(() => {
restoreFavoriteTags();
restoreRecentTags();
restoreQuickAddState(); restoreQuickAddState();
}); });
@@ -143,6 +331,53 @@ onUnmounted(() => {
<template> <template>
<div class="pqa-root"> <div class="pqa-root">
<div class="pqa-toolbar">
<div class="pqa-search-wrap">
<input
v-model="localSearch"
class="pqa-search"
type="search"
placeholder="搜索提示词或翻译..."
/>
<button v-if="localSearch" class="pqa-search-clear" @click="clearSearch" title="清空搜索">
×
</button>
</div>
<div class="pqa-summary">{{ panelSummary }}</div>
</div>
<div v-if="favoriteItems.length" class="pqa-favorites">
<div class="pqa-favorites-title">收藏词</div>
<div class="pqa-chip-list">
<div
v-for="item in favoriteItems"
:key="'favorite_' + item.key"
class="pqa-chip-card"
>
<button
class="pqa-chip pqa-chip-favorite"
draggable="true"
@click="onQuickAddItemClick(item)"
@dragstart="onQuickAddItemDragStart(item, $event)"
@dragend="onTagDragEnd"
:title="item.key"
>
{{ item.translation }}
</button>
<button
class="pqa-fav-btn active"
type="button"
title="取消收藏"
@click.stop="toggleFavorite(item.key)"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2.5l2.94 5.96 6.58.96-4.76 4.64 1.12 6.55L12 17.52 6.12 20.61l1.12-6.55L2.48 9.42l6.58-.96L12 2.5z"/>
</svg>
</button>
</div>
</div>
</div>
<!-- Categories --> <!-- Categories -->
<div class="pqa-categories"> <div class="pqa-categories">
<button v-for="(c, i) in categories" :key="c.id" class="pqa-tab" <button v-for="(c, i) in categories" :key="c.id" class="pqa-tab"
@@ -165,18 +400,67 @@ onUnmounted(() => {
</button> </button>
</div> </div>
<div v-if="recentItems.length" class="pqa-recent">
<div class="pqa-recent-title">最近使用</div>
<div class="pqa-chip-list">
<div
v-for="item in recentItems"
:key="'recent_' + item.key"
class="pqa-chip-card"
>
<button
class="pqa-chip"
draggable="true"
@click="onQuickAddItemClick(item)"
@dragstart="onQuickAddItemDragStart(item, $event)"
@dragend="onTagDragEnd"
:title="item.key"
>
{{ item.translation }}
</button>
<button
class="pqa-fav-btn"
:class="{ active: isFavorite(item.key) }"
type="button"
:title="isFavorite(item.key) ? '取消收藏' : '收藏词条'"
@click.stop="toggleFavorite(item.key)"
>
<svg width="12" height="12" viewBox="0 0 24 24" :fill="isFavorite(item.key) ? 'currentColor' : 'none'" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2.5l2.94 5.96 6.58.96-4.76 4.64 1.12 6.55L12 17.52 6.12 20.61l1.12-6.55L2.48 9.42l6.58-.96L12 2.5z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
</svg>
</button>
</div>
</div>
</div>
<!-- Tags --> <!-- Tags -->
<div class="pqa-tags" ref="tagsContainer" @scroll="onScroll"> <div class="pqa-tags" ref="tagsContainer" @scroll="onScroll">
<button v-for="tag in visibleTags" :key="tag.key" class="pqa-tag" draggable="true" @click="onTagClick(tag)" <div v-for="item in visibleItems" :key="item.key" class="pqa-tag-card">
@dragstart="onTagDragStart(tag, $event)" @dragend="onTagDragEnd" <button class="pqa-tag" draggable="true" @click="onQuickAddItemClick(item)"
:title="tag.key"> @dragstart="onQuickAddItemDragStart(item, $event)" @dragend="onTagDragEnd"
<span class="pqa-tag-text">{{ displayTrans(tag) }}</span> :title="item.key">
<span class="pqa-tag-sub" v-if="displayTrans(tag) !== tag.key">{{ tag.key }}</span> <span class="pqa-tag-text">{{ item.translation }}</span>
</button> <span class="pqa-tag-sub" v-if="item.translation !== item.key">{{ item.key }}</span>
<div v-if="filteredTags.length === 0" class="pqa-empty"> <span class="pqa-tag-path" v-if="localSearch && item.categoryName && item.groupName">
{{ item.categoryName }} / {{ item.groupName }}
</span>
</button>
<button
class="pqa-fav-btn"
:class="{ active: isFavorite(item.key) }"
type="button"
:title="isFavorite(item.key) ? '取消收藏' : '收藏词条'"
@click.stop="toggleFavorite(item.key)"
>
<svg width="12" height="12" viewBox="0 0 24 24" :fill="isFavorite(item.key) ? 'currentColor' : 'none'" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2.5l2.94 5.96 6.58.96-4.76 4.64 1.12 6.55L12 17.52 6.12 20.61l1.12-6.55L2.48 9.42l6.58-.96L12 2.5z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
</svg>
</button>
</div>
<div v-if="allVisibleItems.length === 0" class="pqa-empty">
无相关提示词 无相关提示词
</div> </div>
<div v-if="visibleCount < filteredTags.length" class="pqa-loading-more"> <div v-if="visibleCount < allVisibleItems.length" class="pqa-loading-more">
... ...
</div> </div>
</div> </div>
@@ -196,6 +480,74 @@ onUnmounted(() => {
overflow: hidden; overflow: hidden;
} }
.pqa-toolbar {
display: flex;
flex-direction: column;
gap: 0.5rem;
flex-shrink: 0;
}
.pqa-search-wrap {
position: relative;
}
.pqa-search {
width: 100%;
padding: 0.6rem 2.25rem 0.6rem 0.8rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-primary);
color: var(--color-text-primary);
font-size: 0.8125rem;
transition: all 0.2s ease;
}
.pqa-search:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-light);
}
.pqa-search-clear {
position: absolute;
top: 50%;
right: 0.5rem;
transform: translateY(-50%);
width: 1.5rem;
height: 1.5rem;
border: none;
border-radius: 999px;
background: transparent;
color: var(--color-text-tertiary);
cursor: pointer;
transition: all 0.2s ease;
}
.pqa-search-clear:hover {
background: var(--color-bg-secondary);
color: var(--color-text-primary);
}
.pqa-summary {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.pqa-favorites,
.pqa-recent {
display: flex;
flex-direction: column;
gap: 0.4rem;
flex-shrink: 0;
}
.pqa-favorites-title,
.pqa-recent-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary);
}
.pqa-categories { .pqa-categories {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
@@ -294,6 +646,42 @@ onUnmounted(() => {
background-color: var(--group-color); background-color: var(--group-color);
} }
.pqa-chip-list {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.pqa-chip-card {
display: inline-flex;
align-items: center;
gap: 0.25rem;
max-width: 100%;
}
.pqa-chip {
padding: 0.25rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-bg-primary);
color: var(--color-text-secondary);
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s ease;
}
.pqa-chip:hover {
border-color: var(--color-accent);
color: var(--color-accent);
background: var(--color-bg-secondary);
}
.pqa-chip-favorite {
border-color: color-mix(in srgb, var(--color-accent) 35%, var(--color-border));
background: color-mix(in srgb, var(--color-accent-light) 45%, var(--color-bg-primary));
color: var(--color-accent);
}
.pqa-tags { .pqa-tags {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@@ -304,6 +692,13 @@ onUnmounted(() => {
padding: 0.25rem; padding: 0.25rem;
} }
.pqa-tag-card {
display: flex;
align-items: flex-start;
gap: 0.35rem;
max-width: 100%;
}
.pqa-tag { .pqa-tag {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -317,6 +712,7 @@ onUnmounted(() => {
cursor: pointer; cursor: pointer;
transition: all 0.1s; transition: all 0.1s;
max-width: 100%; max-width: 100%;
text-align: left;
} }
.pqa-tag:hover { .pqa-tag:hover {
@@ -342,6 +738,41 @@ onUnmounted(() => {
line-height: 1; line-height: 1;
} }
.pqa-tag-path {
margin-top: 0.2rem;
font-size: 0.65rem;
color: var(--color-text-tertiary);
line-height: 1.2;
}
.pqa-fav-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.7rem;
height: 1.7rem;
margin-top: 0.1rem;
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-bg-primary);
color: var(--color-text-tertiary);
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.pqa-fav-btn:hover {
border-color: var(--color-accent);
color: var(--color-accent);
background: var(--color-bg-secondary);
}
.pqa-fav-btn.active {
border-color: color-mix(in srgb, var(--color-accent) 45%, var(--color-border));
background: color-mix(in srgb, var(--color-accent-light) 55%, var(--color-bg-primary));
color: var(--color-accent);
}
.pqa-empty { .pqa-empty {
width: 100%; width: 100%;
text-align: center; text-align: center;