refactor(apps): optimize home app list loading and grid layout

1. refactor home app list loading logic: use seen set instead of map, handle deduplication with spark priority
2. add vertical scrolling for small app grid when not using virtual scroll
3. remove unnecessary overflow-y-auto from main container
This commit is contained in:
2026-07-16 10:27:18 +08:00
parent c814c4b13d
commit 387be920b1
2 changed files with 80 additions and 68 deletions
+34 -29
View File
@@ -45,7 +45,7 @@
/> />
</aside> </aside>
<main class="h-full min-h-0 flex-1 overflow-y-auto"> <main class="h-full min-h-0 flex-1">
<div <div
class="sticky top-10 z-30 border-b border-slate-200/70 bg-slate-50 px-4 py-4 lg:px-10 dark:border-slate-800/70 dark:bg-slate-950" class="sticky top-10 z-30 border-b border-slate-200/70 bg-slate-50 px-4 py-4 lg:px-10 dark:border-slate-800/70 dark:bg-slate-950"
> >
@@ -1196,7 +1196,7 @@ const loadHomeListEntries = async () => {
} }
}; };
// 加载首页推荐列表的应用数据(复用首页逻辑,合并展示 spark+apm // 加载首页推荐列表的应用数据(合并展示 spark+apm,按 pkgname 去重,spark 优先
const loadHomeListApps = async (entryId: string) => { const loadHomeListApps = async (entryId: string) => {
if (tabApps.value[entryId]) return; if (tabApps.value[entryId]) return;
// 防止重复加载:如果正在加载中则跳过 // 防止重复加载:如果正在加载中则跳过
@@ -1209,26 +1209,14 @@ const loadHomeListApps = async (entryId: string) => {
loadingTabs.value = new Set(loadingTabs.value).add(entryId); loadingTabs.value = new Set(loadingTabs.value).add(entryId);
const arch = window.apm_store.arch || "amd64"; const arch = window.apm_store.arch || "amd64";
// 按 pkgname 去重,spark 优先(spark 在 modes 数组最前面,先占据 key) const loadedApps: App[] = [];
const appMap = new Map<string, App>(); const seenPkgnames = new Set<string>();
// 同时加载各来源的推荐列表应用
await Promise.all(
(Object.keys(urls) as Array<"spark" | "apm">).map(async (mode) => {
const jsonUrl = urls[mode];
if (!jsonUrl) return;
const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`;
try {
const path = `/${finalArch}${jsonUrl}`;
const rawApps =
(await fetchWithRetry<Record<string, string>[]>(path)) || [];
// 直接使用列表数据构建 App 对象,避免为每个应用单独请求 app.json(N+1 问题)
// 应用详情会在用户点击时通过 fetchAppFromStore 按需获取
for (const a of rawApps) {
const pkgname = a.Pkgname || a.pkgname || "";
if (!pkgname || appMap.has(pkgname)) continue; // 已由更高优先级来源占据
const parseAppList = (
rawApps: Record<string, string>[],
mode: "spark" | "apm",
): App[] =>
rawApps.map((a) => {
const category = a.Category || a.category || "unknown"; const category = a.Category || a.category || "unknown";
let img_urls: string[] = []; let img_urls: string[] = [];
@@ -1243,9 +1231,9 @@ const loadHomeListApps = async (entryId: string) => {
img_urls = rawImgUrls; img_urls = rawImgUrls;
} }
appMap.set(pkgname, { return {
name: a.Name || a.name || pkgname, name: a.Name || a.name || a.Pkgname || a.pkgname || "",
pkgname, pkgname: a.Pkgname || a.pkgname || "",
version: a.Version || "", version: a.Version || "",
filename: a.Filename || a.filename || "", filename: a.Filename || a.filename || "",
torrent_address: a.Torrent_address || "", torrent_address: a.Torrent_address || "",
@@ -1261,22 +1249,39 @@ const loadHomeListApps = async (entryId: string) => {
category, category,
origin: mode, origin: mode,
currentStatus: "not-installed" as const, currentStatus: "not-installed" as const,
} as App); } as App;
});
// 按优先级顺序加载:spark 优先,apm 中与 spark 同名的跳过
const modes: Array<"spark" | "apm"> = ["spark", "apm"];
for (const mode of modes) {
const jsonUrl = urls[mode];
if (!jsonUrl) continue;
const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`;
try {
const path = `/${finalArch}${jsonUrl}`;
const rawApps =
(await fetchWithRetry<Record<string, string>[]>(path)) || [];
const apps = parseAppList(rawApps, mode);
for (const app of apps) {
if (!app.pkgname || seenPkgnames.has(app.pkgname)) continue;
seenPkgnames.add(app.pkgname);
loadedApps.push(app);
} }
} catch (e) { } catch (e) {
logger.warn(`加载首页列表 ${entryId} (${mode}) 失败: ${e}`); logger.warn(`加载首页列表 ${entryId} (${mode}) 失败: ${e}`);
} }
}), }
);
tabApps.value = { ...tabApps.value, [entryId]: [...appMap.values()] }; tabApps.value = { ...tabApps.value, [entryId]: loadedApps };
// 移除加载标记 // 移除加载标记
const next = new Set(loadingTabs.value); const next = new Set(loadingTabs.value);
next.delete(entryId); next.delete(entryId);
loadingTabs.value = next; loadingTabs.value = next;
logger.info(`首页列表 "${entryId}" 加载完成,共 ${appMap.size} 个应用`); logger.info(`首页列表 "${entryId}" 加载完成,共 ${loadedApps.length} 个应用`);
}; };
// 并行预加载所有侧边栏入口的应用数据,避免点击时才加载导致缓慢 // 并行预加载所有侧边栏入口的应用数据,避免点击时才加载导致缓慢
+9 -2
View File
@@ -17,11 +17,12 @@
</p> </p>
</div> </div>
<!-- 应用数量较少时使用普通网格 --> <!-- 应用数量较少时使用普通网格带滚动 -->
<div <div
v-else-if="!loading && apps.length <= 50" v-else-if="!loading && apps.length <= 50"
class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4" class="non-virtual-scroller"
> >
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<AppCard <AppCard
v-for="(app, index) in apps" v-for="(app, index) in apps"
:key="index" :key="index"
@@ -30,6 +31,7 @@
@open-detail="$emit('open-detail', app)" @open-detail="$emit('open-detail', app)"
/> />
</div> </div>
</div>
<!-- 应用数量较多时使用虚拟滚动 --> <!-- 应用数量较多时使用虚拟滚动 -->
<RecycleScroller <RecycleScroller
@@ -180,6 +182,11 @@ const gridRows = computed(() => {
margin: -24px -16px; /* 抵消父容器的 px-4 py-6 */ margin: -24px -16px; /* 抵消父容器的 px-4 py-6 */
} }
.non-virtual-scroller {
height: calc(100vh - 140px);
overflow-y: auto;
}
@media (min-width: 1024px) { @media (min-width: 1024px) {
.scroller { .scroller {
margin: -24px -40px; /* 抵消父容器的 lg:px-10 */ margin: -24px -40px; /* 抵消父容器的 lg:px-10 */