mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-08-06 09:23:56 +08:00
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:
+64
-59
@@ -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,74 +1209,79 @@ 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>();
|
||||||
|
|
||||||
// 同时加载各来源的推荐列表应用
|
const parseAppList = (
|
||||||
await Promise.all(
|
rawApps: Record<string, string>[],
|
||||||
(Object.keys(urls) as Array<"spark" | "apm">).map(async (mode) => {
|
mode: "spark" | "apm",
|
||||||
const jsonUrl = urls[mode];
|
): App[] =>
|
||||||
if (!jsonUrl) return;
|
rawApps.map((a) => {
|
||||||
const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`;
|
const category = a.Category || a.category || "unknown";
|
||||||
|
|
||||||
try {
|
let img_urls: string[] = [];
|
||||||
const path = `/${finalArch}${jsonUrl}`;
|
const rawImgUrls = a.img_urls;
|
||||||
const rawApps =
|
if (typeof rawImgUrls === "string") {
|
||||||
(await fetchWithRetry<Record<string, string>[]>(path)) || [];
|
try {
|
||||||
// 直接使用列表数据构建 App 对象,避免为每个应用单独请求 app.json(N+1 问题)
|
img_urls = JSON.parse(rawImgUrls);
|
||||||
// 应用详情会在用户点击时通过 fetchAppFromStore 按需获取
|
} catch {
|
||||||
for (const a of rawApps) {
|
img_urls = [];
|
||||||
const pkgname = a.Pkgname || a.pkgname || "";
|
|
||||||
if (!pkgname || appMap.has(pkgname)) continue; // 已由更高优先级来源占据
|
|
||||||
|
|
||||||
const category = a.Category || a.category || "unknown";
|
|
||||||
|
|
||||||
let img_urls: string[] = [];
|
|
||||||
const rawImgUrls = a.img_urls;
|
|
||||||
if (typeof rawImgUrls === "string") {
|
|
||||||
try {
|
|
||||||
img_urls = JSON.parse(rawImgUrls);
|
|
||||||
} catch {
|
|
||||||
img_urls = [];
|
|
||||||
}
|
|
||||||
} else if (Array.isArray(rawImgUrls)) {
|
|
||||||
img_urls = rawImgUrls;
|
|
||||||
}
|
|
||||||
|
|
||||||
appMap.set(pkgname, {
|
|
||||||
name: a.Name || a.name || pkgname,
|
|
||||||
pkgname,
|
|
||||||
version: a.Version || "",
|
|
||||||
filename: a.Filename || a.filename || "",
|
|
||||||
torrent_address: a.Torrent_address || "",
|
|
||||||
author: a.Author || "",
|
|
||||||
contributor: a.Contributor || "",
|
|
||||||
website: a.Website || "",
|
|
||||||
update: a.Update || "",
|
|
||||||
size: a.Size || "",
|
|
||||||
more: a.More || a.more || "",
|
|
||||||
tags: a.Tags || "",
|
|
||||||
img_urls,
|
|
||||||
icons: a.icons || "",
|
|
||||||
category,
|
|
||||||
origin: mode,
|
|
||||||
currentStatus: "not-installed" as const,
|
|
||||||
} as App);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} else if (Array.isArray(rawImgUrls)) {
|
||||||
logger.warn(`加载首页列表 ${entryId} (${mode}) 失败: ${e}`);
|
img_urls = rawImgUrls;
|
||||||
}
|
}
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
tabApps.value = { ...tabApps.value, [entryId]: [...appMap.values()] };
|
return {
|
||||||
|
name: a.Name || a.name || a.Pkgname || a.pkgname || "",
|
||||||
|
pkgname: a.Pkgname || a.pkgname || "",
|
||||||
|
version: a.Version || "",
|
||||||
|
filename: a.Filename || a.filename || "",
|
||||||
|
torrent_address: a.Torrent_address || "",
|
||||||
|
author: a.Author || "",
|
||||||
|
contributor: a.Contributor || "",
|
||||||
|
website: a.Website || "",
|
||||||
|
update: a.Update || "",
|
||||||
|
size: a.Size || "",
|
||||||
|
more: a.More || a.more || "",
|
||||||
|
tags: a.Tags || "",
|
||||||
|
img_urls,
|
||||||
|
icons: a.icons || "",
|
||||||
|
category,
|
||||||
|
origin: mode,
|
||||||
|
currentStatus: "not-installed" as const,
|
||||||
|
} 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) {
|
||||||
|
logger.warn(`加载首页列表 ${entryId} (${mode}) 失败: ${e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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} 个应用`);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 并行预加载所有侧边栏入口的应用数据,避免点击时才加载导致缓慢
|
// 并行预加载所有侧边栏入口的应用数据,避免点击时才加载导致缓慢
|
||||||
|
|||||||
@@ -17,18 +17,20 @@
|
|||||||
</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"
|
||||||
>
|
>
|
||||||
<AppCard
|
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||||
v-for="(app, index) in apps"
|
<AppCard
|
||||||
:key="index"
|
v-for="(app, index) in apps"
|
||||||
:app="app"
|
:key="index"
|
||||||
:show-origin="effectiveShowOrigin"
|
:app="app"
|
||||||
@open-detail="$emit('open-detail', app)"
|
:show-origin="effectiveShowOrigin"
|
||||||
/>
|
@open-detail="$emit('open-detail', app)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 应用数量较多时,使用虚拟滚动 -->
|
<!-- 应用数量较多时,使用虚拟滚动 -->
|
||||||
@@ -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 */
|
||||||
|
|||||||
Reference in New Issue
Block a user