feat(search): prioritize app name matches

This commit is contained in:
2026-04-11 16:16:29 +08:00
parent 7bf2a5c55b
commit fa2689c753
3 changed files with 233 additions and 32 deletions

View File

@@ -182,6 +182,10 @@ import {
removeDownloadItem, removeDownloadItem,
watchDownloadsChange, watchDownloadsChange,
} from "./global/downloadStatus"; } from "./global/downloadStatus";
import {
countSearchMatchesByCategory,
rankAppsBySearch,
} from "./modules/appSearch";
import { handleInstall, handleRetry } from "./modules/processInstall"; import { handleInstall, handleRetry } from "./modules/processInstall";
import { createUpdateCenterStore } from "./modules/updateCenter"; import { createUpdateCenterStore } from "./modules/updateCenter";
import type { import type {
@@ -260,7 +264,7 @@ const apmAvailable = ref(false);
const storeFilter = ref<"spark" | "apm" | "both">("both"); const storeFilter = ref<"spark" | "apm" | "both">("both");
// 计算属性 // 计算属性
const filteredApps = computed(() => { const baseApps = computed(() => {
let result = [...apps.value]; let result = [...apps.value];
// 合并相同包名的应用 (混合模式) // 合并相同包名的应用 (混合模式)
@@ -284,50 +288,27 @@ const filteredApps = computed(() => {
result = Array.from(mergedMap.values()); result = Array.from(mergedMap.values());
} }
return result;
});
const filteredApps = computed(() => {
let result = [...baseApps.value];
// 按分类筛选 // 按分类筛选
if (activeCategory.value !== "all") { if (activeCategory.value !== "all") {
result = result.filter((app) => app.category === activeCategory.value); result = result.filter((app) => app.category === activeCategory.value);
} }
// 按搜索关键词筛选
if (searchQuery.value.trim()) { if (searchQuery.value.trim()) {
const q = searchQuery.value.toLowerCase().trim(); return rankAppsBySearch(result, searchQuery.value);
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 result; return result;
}); });
const categoryCounts = computed(() => { const categoryCounts = computed(() => {
// 如果有搜索关键词,显示搜索结果数量
if (searchQuery.value.trim()) { if (searchQuery.value.trim()) {
const q = searchQuery.value.toLowerCase().trim(); return countSearchMatchesByCategory(baseApps.value, searchQuery.value);
const counts: Record<string, number> = { 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;
} }
// 无搜索时显示总数量 // 无搜索时显示总数量

View File

@@ -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> = {},
): 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,
});
});
});

72
src/modules/appSearch.ts Normal file
View File

@@ -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<string, number> => {
const counts: Record<string, number> = { 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;
};