mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-04-30 03:10:16 +08:00
feat(search): prioritize app name matches
This commit is contained in:
45
src/App.vue
45
src/App.vue
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 无搜索时显示总数量
|
// 无搜索时显示总数量
|
||||||
|
|||||||
148
src/__tests__/unit/appSearch.test.ts
Normal file
148
src/__tests__/unit/appSearch.test.ts
Normal 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
72
src/modules/appSearch.ts
Normal 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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user