From 89c11fd68356c9924f3cb99f5ac9423c7ffcbd32 Mon Sep 17 00:00:00 2001 From: ChuwuYo <141227996+ChuwuYo@users.noreply.github.com> Date: Sun, 8 Mar 2026 16:41:45 +0800 Subject: [PATCH] fix(extension): support searching installed plugins by display name (#5806) (#5811) * fix(extension): support searching installed plugins by display name * fix: unify plugin search matching across installed and market tabs * refactor(extension): optimize plugin search matcher and remove redundant checks * refactor(extension-page): centralize search query normalization and text matching logic - Extract `buildSearchQuery` to create normalized query objects from raw input - Extract `matchesText` as a reusable text matching helper for normalized/loose/pinyin/initials matching - Remove unused `marketCustomFilter` to eliminate dead code - Simplify `matchesPluginSearch` to accept query object instead of pre-normalized string - Replace Set with Array for candidates to simplify control flow - Avoid redundant normalization by having callers pass raw strings to `buildSearchQuery` * refactor: remove unused marketCustomFilter from extension page components - Remove marketCustomFilter from destructuring in ExtensionPage.vue, InstalledPluginsTab.vue, and MarketPluginsTab.vue * refactor(extension): extract plugin search utilities into shared module - Create pluginSearch.js to centralize plugin search helpers - Move `normalizeStr`, `normalizeLoose`, `toPinyinText`, and `toInitials` into the shared module - Add `buildSearchQuery`, `matchesText`, and `matchesPluginSearch` for reusable search matching - Refactor useExtensionPage.js to consume the shared utilities - Simplify plugin search logic by consolidating normalization and matching in one place * refactor(extension): add caching to pinyin utilities and extract search fields helper - Add Map-based caching for `toPinyinText` and `toInitials` to avoid redundant pinyin computation - Extract `getPluginSearchFields` function to retrieve plugin fields for searching - Improve plugin search performance with caching and better code organization * perf(extension): add bounded caching for plugin search - cap normalization and pinyin caches with `MAX_SEARCH_CACHE_SIZE` - add `setCacheValue()` for oldest-entry eviction - cache normalized and loose text values to avoid repeated string processing - skip pinyin matching for non-CJK text using Unicode `\p{Unified_Ideograph}` property - improve search performance while keeping memory usage bounded * refactor(extension): extract memoizeLRU helper for cache management - Create `memoizeLRU` higher-order function to generate LRU-cached functions - Replace manual cache implementation with `memoizeLRU` for cleaner code - Optimize `matchesText` to lazily compute looseValue only when needed - Simplify caching logic while maintaining bounded cache size * refactor(extension): simplify memoization and remove LRU logic - Rename `memoizeLRU` to `memoizeStringFn` and remove bounded cache size - Simplify cache hit logic for cleaner code - Remove `MAX_SEARCH_CACHE_SIZE` constant as it's no longer needed --- dashboard/src/utils/pluginSearch.js | 102 ++++++++++++++++++ dashboard/src/views/ExtensionPage.vue | 1 - .../views/extension/InstalledPluginsTab.vue | 1 - .../src/views/extension/MarketPluginsTab.vue | 1 - .../src/views/extension/useExtensionPage.js | 83 +++----------- 5 files changed, 119 insertions(+), 69 deletions(-) create mode 100644 dashboard/src/utils/pluginSearch.js diff --git a/dashboard/src/utils/pluginSearch.js b/dashboard/src/utils/pluginSearch.js new file mode 100644 index 000000000..fc83224f0 --- /dev/null +++ b/dashboard/src/utils/pluginSearch.js @@ -0,0 +1,102 @@ +import { pinyin } from "pinyin-pro"; + +const HAN_IDEOGRAPH_RE = /\p{Unified_Ideograph}/u; + +export const normalizeStr = (s) => (s ?? "").toString().toLowerCase().trim(); + +const normalizeLooseFromNormalized = (normalized) => + normalized.replace(/[\s_-]+/g, "").replace(/[()()【】\[\]{}·•]+/g, ""); + +export const normalizeLoose = (s) => + normalizeLooseFromNormalized(normalizeStr(s)); + +const memoizeStringFn = (fn) => { + const cache = new Map(); + + return (raw) => { + const key = (raw ?? "").toString(); + if (cache.has(key)) { + return cache.get(key); + } + + const value = fn(key); + cache.set(key, value); + return value; + }; +}; + +const getNormalizedText = memoizeStringFn(normalizeStr); + +const getLooseText = memoizeStringFn((text) => + normalizeLooseFromNormalized(getNormalizedText(text)), +); + +export const toPinyinText = memoizeStringFn((text) => + pinyin(text, { toneType: "none" }) + .toLowerCase() + .replace(/\s+/g, ""), +); + +export const toInitials = memoizeStringFn((text) => + pinyin(text, { pattern: "first", toneType: "none" }) + .toLowerCase() + .replace(/\s+/g, ""), +); + +export const buildSearchQuery = (raw) => { + const norm = getNormalizedText(raw); + if (!norm) return null; + return { + norm, + loose: getLooseText(raw), + }; +}; + +export const matchesText = (value, query) => { + if (value == null || !query?.norm) return false; + const text = String(value); + + const normalizedValue = getNormalizedText(text); + const looseValue = query.loose ? getLooseText(text) : null; + + if (normalizedValue.includes(query.norm)) return true; + if (query.loose && looseValue?.includes(query.loose)) return true; + + if (!HAN_IDEOGRAPH_RE.test(text)) return false; + + const pinyinValue = toPinyinText(text); + if (pinyinValue.includes(query.norm)) return true; + + const initialsValue = toInitials(text); + if (initialsValue.includes(query.norm)) return true; + + return false; +}; + +export const getPluginSearchFields = (plugin) => { + const supportPlatforms = Array.isArray(plugin?.support_platforms) + ? plugin.support_platforms.join(" ") + : ""; + const tags = Array.isArray(plugin?.tags) ? plugin.tags.join(" ") : ""; + + return [ + plugin?.name, + plugin?.trimmedName, + plugin?.display_name, + plugin?.desc, + plugin?.author, + plugin?.repo, + plugin?.version, + plugin?.astrbot_version, + supportPlatforms, + tags, + ]; +}; + +export const matchesPluginSearch = (plugin, query) => { + if (!query) return true; + + return getPluginSearchFields(plugin).some((candidate) => + matchesText(candidate, query), + ); +}; diff --git a/dashboard/src/views/ExtensionPage.vue b/dashboard/src/views/ExtensionPage.vue index 4703ff569..657b20351 100644 --- a/dashboard/src/views/ExtensionPage.vue +++ b/dashboard/src/views/ExtensionPage.vue @@ -84,7 +84,6 @@ const { normalizeStr, toPinyinText, toInitials, - marketCustomFilter, plugin_handler_info_headers, pluginHeaders, filteredExtensions, diff --git a/dashboard/src/views/extension/InstalledPluginsTab.vue b/dashboard/src/views/extension/InstalledPluginsTab.vue index 1e7c7fefa..442ae2433 100644 --- a/dashboard/src/views/extension/InstalledPluginsTab.vue +++ b/dashboard/src/views/extension/InstalledPluginsTab.vue @@ -81,7 +81,6 @@ const { normalizeStr, toPinyinText, toInitials, - marketCustomFilter, plugin_handler_info_headers, pluginHeaders, filteredExtensions, diff --git a/dashboard/src/views/extension/MarketPluginsTab.vue b/dashboard/src/views/extension/MarketPluginsTab.vue index dccf67aee..00bc3bbc3 100644 --- a/dashboard/src/views/extension/MarketPluginsTab.vue +++ b/dashboard/src/views/extension/MarketPluginsTab.vue @@ -82,7 +82,6 @@ const { normalizeStr, toPinyinText, toInitials, - marketCustomFilter, plugin_handler_info_headers, pluginHeaders, filteredExtensions, diff --git a/dashboard/src/views/extension/useExtensionPage.js b/dashboard/src/views/extension/useExtensionPage.js index f57aad6d9..8fd2aff08 100644 --- a/dashboard/src/views/extension/useExtensionPage.js +++ b/dashboard/src/views/extension/useExtensionPage.js @@ -1,9 +1,15 @@ import axios from "axios"; -import { pinyin } from "pinyin-pro"; import { useCommonStore } from "@/stores/common"; import { useI18n, useModuleI18n } from "@/i18n/composables"; import { getPlatformDisplayName } from "@/utils/platformUtils"; import { resolveErrorMessage } from "@/utils/errorUtils"; +import { + buildSearchQuery, + matchesPluginSearch, + normalizeStr, + toInitials, + toPinyinText, +} from "@/utils/pluginSearch"; import { ref, computed, onMounted, onUnmounted, reactive, watch } from "vue"; import { useRoute, useRouter } from "vue-router"; import { useDisplay } from "vuetify"; @@ -240,37 +246,6 @@ export const useExtensionPage = () => { }); // 插件市场拼音搜索 - const normalizeStr = (s) => (s ?? "").toString().toLowerCase().trim(); - const toPinyinText = (s) => - pinyin(s ?? "", { toneType: "none" }) - .toLowerCase() - .replace(/\s+/g, ""); - const toInitials = (s) => - pinyin(s ?? "", { pattern: "first", toneType: "none" }) - .toLowerCase() - .replace(/\s+/g, ""); - const marketCustomFilter = (value, query, item) => { - const q = normalizeStr(query); - if (!q) return true; - - const candidates = new Set(); - if (value != null) candidates.add(String(value)); - if (item?.name) candidates.add(String(item.name)); - if (item?.trimmedName) candidates.add(String(item.trimmedName)); - if (item?.display_name) candidates.add(String(item.display_name)); - if (item?.desc) candidates.add(String(item.desc)); - if (item?.author) candidates.add(String(item.author)); - - for (const v of candidates) { - const nv = normalizeStr(v); - if (nv.includes(q)) return true; - const pv = toPinyinText(v); - if (pv.includes(q)) return true; - const iv = toInitials(v); - if (iv.includes(q)) return true; - } - return false; - }; const plugin_handler_info_headers = computed(() => [ { title: tm("table.headers.eventType"), key: "event_type_h" }, @@ -347,47 +322,24 @@ export const useExtensionPage = () => { // 通过搜索过滤插件 const filteredPlugins = computed(() => { const plugins = filteredExtensions.value; - let filtered = plugins; - - if (pluginSearch.value) { - const search = pluginSearch.value.toLowerCase(); - filtered = plugins.filter((plugin) => { - const pluginName = (plugin.name ?? "").toLowerCase(); - const pluginDesc = (plugin.desc ?? "").toLowerCase(); - const pluginAuthor = (plugin.author ?? "").toLowerCase(); - const supportPlatforms = Array.isArray(plugin.support_platforms) - ? plugin.support_platforms.join(" ").toLowerCase() - : ""; - const astrbotVersion = (plugin.astrbot_version ?? "").toLowerCase(); - - return ( - pluginName.includes(search) || - pluginDesc.includes(search) || - pluginAuthor.includes(search) || - supportPlatforms.includes(search) || - astrbotVersion.includes(search) - ); - }); - } + const query = buildSearchQuery(pluginSearch.value); + const filtered = query + ? plugins.filter((plugin) => matchesPluginSearch(plugin, query)) + : plugins; return sortPluginsByName([...filtered]); }); // 过滤后的插件市场数据(带搜索) const filteredMarketPlugins = computed(() => { - if (!debouncedMarketSearch.value) { + const query = buildSearchQuery(debouncedMarketSearch.value); + if (!query) { return pluginMarketData.value; } - - const search = debouncedMarketSearch.value.toLowerCase(); - return pluginMarketData.value.filter((plugin) => { - // 使用自定义过滤器 - return ( - marketCustomFilter(plugin.name, search, plugin) || - marketCustomFilter(plugin.desc, search, plugin) || - marketCustomFilter(plugin.author, search, plugin) - ); - }); + + return pluginMarketData.value.filter((plugin) => + matchesPluginSearch(plugin, query), + ); }); // 所有插件列表,推荐插件排在前面 @@ -1563,7 +1515,6 @@ export const useExtensionPage = () => { normalizeStr, toPinyinText, toInitials, - marketCustomFilter, plugin_handler_info_headers, pluginHeaders, filteredExtensions,