diff --git a/src/App.vue b/src/App.vue index 95993248..bdaccf0e 100644 --- a/src/App.vue +++ b/src/App.vue @@ -182,6 +182,10 @@ import { removeDownloadItem, watchDownloadsChange, } from "./global/downloadStatus"; +import { + countSearchMatchesByCategory, + rankAppsBySearch, +} from "./modules/appSearch"; import { handleInstall, handleRetry } from "./modules/processInstall"; import { createUpdateCenterStore } from "./modules/updateCenter"; import type { @@ -260,7 +264,7 @@ const apmAvailable = ref(false); const storeFilter = ref<"spark" | "apm" | "both">("both"); // 计算属性 -const filteredApps = computed(() => { +const baseApps = computed(() => { let result = [...apps.value]; // 合并相同包名的应用 (混合模式) @@ -284,50 +288,27 @@ const filteredApps = computed(() => { result = Array.from(mergedMap.values()); } + return result; +}); + +const filteredApps = computed(() => { + let result = [...baseApps.value]; + // 按分类筛选 if (activeCategory.value !== "all") { result = result.filter((app) => app.category === activeCategory.value); } - // 按搜索关键词筛选 if (searchQuery.value.trim()) { - const q = searchQuery.value.toLowerCase().trim(); - result = result.filter((app) => { - // 兼容可能为 undefined 的情况,虽然类型定义是 string - return ( - (app.name || "").toLowerCase().includes(q) || - (app.pkgname || "").toLowerCase().includes(q) || - (app.tags || "").toLowerCase().includes(q) || - (app.more || "").toLowerCase().includes(q) - ); - }); + return rankAppsBySearch(result, searchQuery.value); } return result; }); const categoryCounts = computed(() => { - // 如果有搜索关键词,显示搜索结果数量 if (searchQuery.value.trim()) { - const q = searchQuery.value.toLowerCase().trim(); - const counts: Record = { all: 0 }; - - apps.value.forEach((app) => { - // 检查应用是否匹配搜索条件 - const matches = - (app.name || "").toLowerCase().includes(q) || - (app.pkgname || "").toLowerCase().includes(q) || - (app.tags || "").toLowerCase().includes(q) || - (app.more || "").toLowerCase().includes(q); - - if (matches) { - counts.all++; - if (!counts[app.category]) counts[app.category] = 0; - counts[app.category]++; - } - }); - - return counts; + return countSearchMatchesByCategory(baseApps.value, searchQuery.value); } // 无搜索时显示总数量 diff --git a/src/__tests__/unit/appSearch.test.ts b/src/__tests__/unit/appSearch.test.ts new file mode 100644 index 00000000..0297a157 --- /dev/null +++ b/src/__tests__/unit/appSearch.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; + +import { + countSearchMatchesByCategory, + getSearchMatchScore, + matchesSearch, + rankAppsBySearch, +} from "@/modules/appSearch"; +import type { App } from "@/global/typedefinition"; + +const createApp = ( + name: string, + pkgname: string, + overrides: Partial = {}, +): App => ({ + name, + pkgname, + version: "1.0.0", + filename: "app.deb", + torrent_address: "", + author: "", + contributor: "", + website: "", + update: "", + size: "1 MB", + more: "", + tags: "", + img_urls: [], + icons: "", + category: "tools", + origin: "spark", + currentStatus: "not-installed", + ...overrides, +}); + +describe("app search score", () => { + it("scores a name match above a description-only match", () => { + const byName = createApp("维护打包工具箱", "uos-packaging-tools"); + const byMore = createApp("QQ", "linuxqq", { + more: "用于系统维护的聊天软件", + }); + + expect(getSearchMatchScore(byName, "维护")).toBeGreaterThan( + getSearchMatchScore(byMore, "维护"), + ); + }); + + it("scores a name prefix match above a name contains match", () => { + const prefix = createApp("维护打包工具箱", "toolbox"); + const contains = createApp("桌面维护助手", "desktop-maintainer"); + + expect(getSearchMatchScore(prefix, "维护")).toBeGreaterThan( + getSearchMatchScore(contains, "维护"), + ); + }); + + it("scores a name exact match above a name prefix match", () => { + const exact = createApp("维护", "exact-match"); + const prefix = createApp("维护打包工具箱", "prefix-match"); + + expect(getSearchMatchScore(exact, "维护")).toBeGreaterThan( + getSearchMatchScore(prefix, "维护"), + ); + }); + + it("scores a pkgname match above tags and description matches", () => { + const byPkgname = createApp("工具箱", "maintenance-toolbox"); + const byTags = createApp("应用 A", "app-a", { tags: "maintenance;tools" }); + const byMore = createApp("应用 B", "app-b", { + more: "maintenance related guide", + }); + + expect(getSearchMatchScore(byPkgname, "maintenance")).toBeGreaterThan( + getSearchMatchScore(byTags, "maintenance"), + ); + expect(getSearchMatchScore(byPkgname, "maintenance")).toBeGreaterThan( + getSearchMatchScore(byMore, "maintenance"), + ); + }); + + it("matches only against the normalized literal query", () => { + const app = createApp("Toolbox", "maintenance-toolbox", { + tags: "maintenance;tools", + more: "maintenance related guide", + }); + + expect(getSearchMatchScore(app, "维护")).toBe(0); + expect(matchesSearch(app, "维护")).toBe(false); + }); + + it("reports whether an app matches the query", () => { + const matched = createApp("维护打包工具箱", "uos-packaging-tools"); + const ignored = createApp("Firefox", "firefox-spark", { + more: "浏览器", + }); + + expect(matchesSearch(matched, "维护")).toBe(true); + expect(matchesSearch(ignored, "维护")).toBe(false); + }); + + it("ranks apps in name, pkgname, tags, then description order", () => { + const byName = createApp("maintenance 打包工具箱", "uos-packaging-tools"); + const byPkgname = createApp("工具箱", "maintenance-toolbox"); + const byTags = createApp("应用 A", "app-a", { tags: "maintenance;tool" }); + const byMore = createApp("QQ", "linuxqq", { + more: "maintenance related chat software", + }); + const nonMatch = createApp("Firefox", "firefox", { + more: "browser", + }); + + expect( + rankAppsBySearch( + [byMore, nonMatch, byTags, byPkgname, byName], + "maintenance", + ).map((app) => app.pkgname), + ).toEqual([ + "uos-packaging-tools", + "maintenance-toolbox", + "app-a", + "linuxqq", + ]); + }); + + it("keeps original order when scores tie and counts matches by category", () => { + const first = createApp("maintenance tool A", "maint-a", { + category: "tools", + }); + const second = createApp("maintenance tool B", "maint-b", { + category: "tools", + }); + const browser = createApp("Firefox", "firefox", { + category: "internet", + more: "browser", + }); + + expect(rankAppsBySearch([first, second], "maintenance")).toEqual([ + first, + second, + ]); + expect( + countSearchMatchesByCategory([first, second, browser], "maintenance"), + ).toEqual({ + all: 2, + tools: 2, + }); + }); +}); diff --git a/src/modules/appSearch.ts b/src/modules/appSearch.ts new file mode 100644 index 00000000..3166e99c --- /dev/null +++ b/src/modules/appSearch.ts @@ -0,0 +1,72 @@ +import type { App } from "@/global/typedefinition"; + +const normalizeSearchValue = (value: string | undefined): string => + (value ?? "").toLowerCase().trim(); + +const getTieredMatchScore = ( + value: string | undefined, + query: string, + exactScore: number, + prefixScore: number, + includesScore: number, +): number => { + const normalizedValue = normalizeSearchValue(value); + + if (!normalizedValue || !query) return 0; + if (normalizedValue === query) return exactScore; + if (normalizedValue.startsWith(query)) return prefixScore; + if (normalizedValue.includes(query)) return includesScore; + return 0; +}; + +export const getSearchMatchScore = (app: App, query: string): number => { + const normalizedQuery = normalizeSearchValue(query); + + if (!normalizedQuery) return 0; + + return Math.max( + getTieredMatchScore(app.name, normalizedQuery, 400, 300, 200), + getTieredMatchScore(app.pkgname, normalizedQuery, 190, 180, 170), + getTieredMatchScore(app.tags, normalizedQuery, 160, 150, 140), + getTieredMatchScore(app.more, normalizedQuery, 130, 120, 110), + ); +}; + +export const matchesSearch = (app: App, query: string): boolean => + getSearchMatchScore(app, query) > 0; + +export const rankAppsBySearch = (apps: App[], query: string): App[] => + apps + .map((app, index) => ({ + app, + index, + score: getSearchMatchScore(app, query), + })) + .filter((entry) => entry.score > 0) + .sort((left, right) => { + if (right.score !== left.score) { + return right.score - left.score; + } + + return left.index - right.index; + }) + .map((entry) => entry.app); + +export const countSearchMatchesByCategory = ( + apps: App[], + query: string, +): Record => { + const counts: Record = { all: 0 }; + + apps.forEach((app) => { + if (!matchesSearch(app, query)) { + return; + } + + counts.all++; + if (!counts[app.category]) counts[app.category] = 0; + counts[app.category]++; + }); + + return counts; +};