refactor(home): 重构首页与侧边栏,移除旧HomeList类型并支持首页列表侧边栏入口

1.  移除废弃的HomeList类型定义,新增homeList侧边栏类型
2.  重构AppGrid组件,添加showOrigin属性支持自定义显示来源标记
3.  简化HomeView组件,移除旧的列表展示逻辑与相关代码
4.  新增侧边栏首页列表入口加载与应用数据加载逻辑
5.  优化侧边栏计数与应用预加载逻辑,适配新的首页列表类型
This commit is contained in:
2026-07-13 22:57:54 +08:00
parent 7c6bdf0e55
commit 6328f178d3
4 changed files with 251 additions and 156 deletions
+237 -101
View File
@@ -86,14 +86,14 @@
@open-detail="openDetail" @open-detail="openDetail"
/> />
<template v-else-if="activeTab === 'home'"> <template v-else-if="activeTab === 'home'">
<div class="max-h-[calc(100vh-8rem)] overflow-y-auto pr-2 scrollbar-nowidth"> <div
class="max-h-[calc(100vh-8rem)] overflow-y-auto pr-2 scrollbar-nowidth"
>
<HomeView <HomeView
:links="homeLinks" :links="homeLinks"
:lists="homeLists"
:loading="homeLoading" :loading="homeLoading"
:error="homeError" :error="homeError"
:store-filter="storeFilter" :store-filter="storeFilter"
@open-detail="openDetail"
/> />
</div> </div>
</template> </template>
@@ -103,7 +103,8 @@
:loading="loading" :loading="loading"
:scroll-key="activeTab + '-' + selectedCategory" :scroll-key="activeTab + '-' + selectedCategory"
:store-filter="storeFilter" :store-filter="storeFilter"
@open-detail="openDetail" :show-origin="storeFilter === 'both' && !isHomeListTab"
@open-detail="handleAppCardOpenDetail"
/> />
</template> </template>
</div> </div>
@@ -388,7 +389,6 @@ import type {
ChannelPayload, ChannelPayload,
CategoryInfo, CategoryInfo,
HomeLink, HomeLink,
HomeList,
FlarumLoginPayload, FlarumLoginPayload,
SidebarEntry, SidebarEntry,
UpdateCenterItem, UpdateCenterItem,
@@ -448,6 +448,8 @@ const tabCategories: Ref<Record<string, Record<string, CategoryInfo>>> = ref(
{}, {},
); );
const tabApps: Ref<Record<string, App[]>> = ref({}); const tabApps: Ref<Record<string, App[]>> = ref({});
// 首页推荐列表入口对应的各来源 jsonUrl{ [entryId]: { spark?, apm? } }
const homeListUrls = ref<Record<string, { spark?: string; apm?: string }>>({});
const activeTab = ref("home"); const activeTab = ref("home");
type MainView = "default" | "favorites"; type MainView = "default" | "favorites";
const currentView = ref<MainView>("default"); const currentView = ref<MainView>("default");
@@ -601,9 +603,14 @@ const entryCounts = computed(() => {
sidebarEntries.value.forEach((entry) => { sidebarEntries.value.forEach((entry) => {
if (entry.type === "category" && entry.value) { if (entry.type === "category" && entry.value) {
counts[entry.id] = allApps.filter( // 优先使用已加载的入口应用总数;未加载时回退到全局分类计数
(app) => app.category === entry.value, const tabLen = tabApps.value[entry.id]?.length;
).length; counts[entry.id] =
tabLen !== undefined
? tabLen
: allApps.filter((app) => app.category === entry.value).length;
} else if (entry.type === "homeList") {
counts[entry.id] = tabApps.value[entry.id]?.length || 0;
} else { } else {
counts[entry.id] = 0; counts[entry.id] = 0;
} }
@@ -614,6 +621,25 @@ const entryCounts = computed(() => {
const currentDisplayApp = computed(() => getDisplayApp(currentApp.value)); const currentDisplayApp = computed(() => getDisplayApp(currentApp.value));
// 当前激活的侧栏入口是否为首页推荐列表类型
const isHomeListTab = computed(
() =>
activeTab.value !== "home" &&
activeTab.value !== "all" &&
sidebarEntries.value.some(
(e) => e.id === activeTab.value && e.type === "homeList",
),
);
// 应用卡片点击:首页推荐列表复用首页逻辑,标记 _fromHomeView 以便从双仓库获取完整信息
const handleAppCardOpenDetail = (app: App) => {
if (isHomeListTab.value) {
openDetail({ ...app, _fromHomeView: true });
} else {
openDetail(app);
}
};
const clientArch = computed(() => window.apm_store.arch || "amd64"); const clientArch = computed(() => window.apm_store.arch || "amd64");
const currentReviewAppKey = computed(() => { const currentReviewAppKey = computed(() => {
@@ -731,17 +757,15 @@ const selectTab = (tab: string) => {
selectedCategory.value = "all"; selectedCategory.value = "all";
isSidebarOpen.value = false; isSidebarOpen.value = false;
window.scrollTo({ top: 0, behavior: "smooth" }); window.scrollTo({ top: 0, behavior: "smooth" });
if ( if (tab === "home" && homeLinks.value.length === 0) {
tab === "home" &&
homeLinks.value.length === 0 &&
homeLists.value.length === 0
) {
loadHome(); loadHome();
} }
if (tab !== "home" && tab !== "all") { if (tab !== "home" && tab !== "all") {
const entry = sidebarEntries.value.find((e) => e.id === tab); const entry = sidebarEntries.value.find((e) => e.id === tab);
if (entry && entry.type === "category") { if (entry && entry.type === "category") {
loadTabApps(tab); loadTabApps(tab);
} else if (entry && entry.type === "homeList") {
loadHomeListApps(tab);
} }
} }
}; };
@@ -1036,7 +1060,6 @@ const closeScreenPreview = () => {
// Home data // Home data
const homeLinks = ref<HomeLink[]>([]); const homeLinks = ref<HomeLink[]>([]);
const homeLists = ref<HomeList[]>([]);
const homeLoading = ref(false); const homeLoading = ref(false);
const homeError = ref(""); const homeError = ref("");
@@ -1044,7 +1067,6 @@ const loadHome = async () => {
homeLoading.value = true; homeLoading.value = true;
homeError.value = ""; homeError.value = "";
homeLinks.value = []; homeLinks.value = [];
homeLists.value = [];
try { try {
const arch = window.apm_store.arch || "amd64"; const arch = window.apm_store.arch || "amd64";
const modes: Array<"spark" | "apm"> = const modes: Array<"spark" | "apm"> =
@@ -1068,65 +1090,6 @@ const loadHome = async () => {
} catch (e) { } catch (e) {
console.warn(`Failed to load ${mode} homelinks.json`, e); console.warn(`Failed to load ${mode} homelinks.json`, e);
} }
// homelist.json
try {
const res2 = await fetch(`${base}/homelist.json`);
if (res2.ok) {
const lists = await res2.json();
for (const item of lists) {
if (item.type === "appList" && item.jsonUrl) {
try {
const url = `${APM_STORE_BASE_URL}/${finalArch}${item.jsonUrl}`;
const r = await fetch(url);
if (r.ok) {
const appsJson = await r.json();
const rawApps = appsJson || [];
const apps = await Promise.all(
rawApps.map(async (a: Record<string, string>) => {
const baseApp = {
name: a.Name || a.name || a.Pkgname || a.PkgName || "",
pkgname: a.Pkgname || a.pkgname || "",
category: a.Category || a.category || "unknown",
more: a.More || a.more || "",
version: a.Version || "",
filename: a.Filename || a.filename || "",
origin: mode as "spark" | "apm",
};
try {
const realAppUrl = `${APM_STORE_BASE_URL}/${finalArch}/${baseApp.category}/${baseApp.pkgname}/app.json`;
const realRes = await fetch(realAppUrl);
if (realRes.ok) {
const realApp = await realRes.json();
if (realApp.Filename)
baseApp.filename = realApp.Filename;
if (realApp.More) baseApp.more = realApp.More;
if (realApp.Name) baseApp.name = realApp.Name;
}
} catch (e) {
console.warn(
`Failed to fetch real app.json for ${baseApp.pkgname}`,
e,
);
}
return baseApp;
}),
);
homeLists.value.push({
title: `${item.name || "推荐"} (${mode === "spark" ? "星火" : "APM"})`,
apps,
});
}
} catch (e) {
console.warn("Failed to load home list", item, e);
}
}
}
}
} catch (e) {
console.warn(`Failed to load ${mode} homelist.json`, e);
}
} }
} catch (error: unknown) { } catch (error: unknown) {
homeError.value = (error as Error)?.message || "加载首页失败"; homeError.value = (error as Error)?.message || "加载首页失败";
@@ -1135,6 +1098,166 @@ const loadHome = async () => {
} }
}; };
// 加载首页推荐列表为侧边栏入口(按名称合并 spark/apm,置于分类入口上方)
const loadHomeListEntries = async () => {
try {
const arch = window.apm_store.arch || "amd64";
const modes: Array<"spark" | "apm"> =
storeFilter.value === "both" ? ["spark", "apm"] : [storeFilter.value];
// 按列表名称合并各来源的 jsonUrl
const byName = new Map<
string,
{ name: string; urls: { spark?: string; apm?: string } }
>();
for (const mode of modes) {
const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`;
const base = `${APM_STORE_BASE_URL}/${finalArch}/home`;
try {
const res = await fetch(`${base}/homelist.json`);
if (res.ok) {
const lists = await res.json();
lists.forEach(
(item: { name?: string; type?: string; jsonUrl?: string }) => {
if (item.type === "appList" && item.jsonUrl) {
const name = item.name || "推荐";
const existing = byName.get(name);
if (existing) {
existing.urls[mode] = item.jsonUrl;
} else {
byName.set(name, {
name,
urls: { [mode]: item.jsonUrl } as {
spark?: string;
apm?: string;
},
});
}
}
},
);
}
} catch (e) {
console.warn(`Failed to load ${mode} homelist.json`, e);
}
}
const entries: SidebarEntry[] = [];
const urlsMap: Record<string, { spark?: string; apm?: string }> = {};
byName.forEach((info, name) => {
const id = `home-list-${name}`;
entries.push({
id,
name,
icon: "fas fa-star",
type: "homeList",
});
urlsMap[id] = info.urls;
});
if (entries.length > 0) {
// 首页推荐入口置于分类入口上方
sidebarEntries.value = [...entries, ...sidebarEntries.value];
homeListUrls.value = { ...homeListUrls.value, ...urlsMap };
logger.info(`已加载 ${entries.length} 个首页推荐列表入口`);
}
} catch (error) {
logger.warn(`加载首页推荐列表入口失败: ${error}`);
}
};
// 加载首页推荐列表的应用数据(复用首页逻辑,合并展示 spark+apm)
const loadHomeListApps = async (entryId: string) => {
if (tabApps.value[entryId]) return;
const urls = homeListUrls.value[entryId];
if (!urls) return;
const arch = window.apm_store.arch || "amd64";
const loadedApps: App[] = [];
// 同时加载各来源的推荐列表应用
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)) || [];
const apps = await Promise.all(
rawApps.map(async (a) => {
const category = a.Category || a.category || "unknown";
const pkgname = a.Pkgname || a.pkgname || "";
// 复用首页逻辑:从仓库获取完整应用信息
try {
const realAppUrl = `/${finalArch}/${category}/${pkgname}/app.json`;
const realApp = await fetchWithRetry<AppJson>(realAppUrl);
return normalizeAppJson(realApp, category, mode);
} catch (e) {
console.warn(`Failed to fetch app.json for ${pkgname}`, e);
}
// 回退:使用列表中的基本信息构建 App 对象
return {
name: a.Name || a.name || pkgname || "",
pkgname,
version: a.Version || "",
filename: a.Filename || a.filename || "",
category,
more: a.More || a.more || "",
torrent_address: "",
author: "",
contributor: "",
website: "",
update: "",
size: "",
tags: "",
img_urls: [],
icons: "",
origin: mode,
currentStatus: "not-installed" as const,
} as App;
}),
);
loadedApps.push(...apps);
} catch (e) {
logger.warn(`加载首页列表 ${entryId} (${mode}) 失败: ${e}`);
}
}),
);
tabApps.value = { ...tabApps.value, [entryId]: loadedApps };
logger.info(`首页列表 "${entryId}" 加载完成,共 ${loadedApps.length} 个应用`);
};
// 并行预加载所有侧边栏入口的应用数据,避免点击时才加载导致缓慢
const preloadSidebarTabApps = (): Promise<void> => {
const tasks: Promise<void>[] = [];
for (const entry of sidebarEntries.value) {
if (entry.type === "category") {
tasks.push(
loadTabApps(entry.id).catch((e: unknown) =>
logger.warn(`预加载入口 ${entry.id} 失败: ${e}`),
),
);
} else if (entry.type === "homeList") {
tasks.push(
loadHomeListApps(entry.id).catch((e: unknown) =>
logger.warn(`预加载首页列表 ${entry.id} 失败: ${e}`),
),
);
}
}
return Promise.all(tasks).then(() => undefined);
};
const prevScreen = () => { const prevScreen = () => {
if (currentScreenIndex.value > 0) { if (currentScreenIndex.value > 0) {
currentScreenIndex.value--; currentScreenIndex.value--;
@@ -2449,9 +2572,11 @@ const loadTabApps = async (entryId: string) => {
const modes: Array<"spark" | "apm"> = const modes: Array<"spark" | "apm"> =
storeFilter.value === "both" ? ["spark", "apm"] : [storeFilter.value]; storeFilter.value === "both" ? ["spark", "apm"] : [storeFilter.value];
const folderName = entry.value || entry.id; const folderName = entry.value || entry.id;
const loadedApps: App[] = [];
const subCats = tabCategories.value[entryId]; const subCats = tabCategories.value[entryId];
// 收集所有需要发起的请求任务(mode × 子分类),然后全并发加载
const tasks: Promise<App[]>[] = [];
for (const mode of modes) { for (const mode of modes) {
const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`; const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`;
@@ -2464,37 +2589,44 @@ const loadTabApps = async (entryId: string) => {
) )
continue; continue;
try { const path = `/${finalArch}/${folderName}/${subCat}/applist.json`;
const path = `/${finalArch}/${folderName}/${subCat}/applist.json`; logger.info(`加载入口子分类: ${entryId}/${subCat} (来源: ${mode})`);
logger.info(`加载入口子分类: ${entryId}/${subCat} (来源: ${mode})`); tasks.push(
const categoryApps = await fetchWithRetry<AppJson[]>(path); fetchWithRetry<AppJson[]>(path)
loadedApps.push( .then((categoryApps) =>
...(categoryApps || []).map((aj) => (categoryApps || []).map((aj) =>
normalizeAppJson(aj, subCat, mode), normalizeAppJson(aj, subCat, mode),
), ),
); )
} catch (e) { .catch((e: unknown) => {
logger.warn( logger.warn(
`加载入口子分类 ${entryId}/${subCat} (${mode}) 失败: ${e}`, `加载入口子分类 ${entryId}/${subCat} (${mode}) 失败: ${e}`,
); );
} return [] as App[];
}),
);
} }
} else { } else {
try { const path = `/${finalArch}/${folderName}/applist.json`;
const path = `/${finalArch}/${folderName}/applist.json`; logger.info(`加载入口目录: ${entryId} (来源: ${mode})`);
logger.info(`加载入口目录: ${entryId} (来源: ${mode})`); tasks.push(
const categoryApps = await fetchWithRetry<AppJson[]>(path); fetchWithRetry<AppJson[]>(path)
loadedApps.push( .then((categoryApps) =>
...(categoryApps || []).map((aj) => (categoryApps || []).map((aj) =>
normalizeAppJson(aj, folderName, mode), normalizeAppJson(aj, folderName, mode),
), ),
); )
} catch (e) { .catch((e: unknown) => {
logger.warn(`加载入口目录 ${entryId} (${mode}) 失败: ${e}`); logger.warn(`加载入口目录 ${entryId} (${mode}) 失败: ${e}`);
} return [] as App[];
}),
);
} }
} }
const results = await Promise.all(tasks);
const loadedApps = results.flat();
tabApps.value = { ...tabApps.value, [entryId]: loadedApps }; tabApps.value = { ...tabApps.value, [entryId]: loadedApps };
logger.info(`入口 "${entryId}" 加载完成,共 ${loadedApps.length} 个应用`); logger.info(`入口 "${entryId}" 加载完成,共 ${loadedApps.length} 个应用`);
}; };
@@ -2608,6 +2740,8 @@ onMounted(async () => {
await loadSidebarConfig(); await loadSidebarConfig();
await loadHomeListEntries();
await loadTabCategories(); await loadTabCategories();
// 分类目录加载后,并行加载主页数据和所有应用列表 // 分类目录加载后,并行加载主页数据和所有应用列表
@@ -2624,6 +2758,8 @@ onMounted(async () => {
resolve(); resolve();
}); });
}), }),
// 并行预加载所有侧边栏入口的应用数据
preloadSidebarTabApps(),
]).then(() => { ]).then(() => {
// 所有数据加载完成后的回调(可选) // 所有数据加载完成后的回调(可选)
logger.info("所有应用数据加载完成"); logger.info("所有应用数据加载完成");
+10 -2
View File
@@ -26,7 +26,7 @@
v-for="(app, index) in apps" v-for="(app, index) in apps"
:key="index" :key="index"
:app="app" :app="app"
:show-origin="storeFilter === 'both'" :show-origin="effectiveShowOrigin"
@open-detail="$emit('open-detail', app)" @open-detail="$emit('open-detail', app)"
/> />
</div> </div>
@@ -46,7 +46,7 @@
v-for="app in item.apps" v-for="app in item.apps"
:key="app.pkgname" :key="app.pkgname"
:app="app" :app="app"
:show-origin="storeFilter === 'both'" :show-origin="effectiveShowOrigin"
@open-detail="$emit('open-detail', app)" @open-detail="$emit('open-detail', app)"
/> />
</div> </div>
@@ -93,8 +93,16 @@ const props = defineProps<{
loading: boolean; loading: boolean;
storeFilter?: "spark" | "apm" | "both"; storeFilter?: "spark" | "apm" | "both";
scrollKey?: string; scrollKey?: string;
showOrigin?: boolean;
}>(); }>();
// 显式传入 showOrigin 时优先使用;否则回退到根据 storeFilter 推断
const effectiveShowOrigin = computed(() =>
props.showOrigin !== undefined
? props.showOrigin
: props.storeFilter === "both",
);
defineEmits<{ defineEmits<{
(e: "open-detail", app: App): void; (e: "open-detail", app: App): void;
}>(); }>();
+3 -47
View File
@@ -2,7 +2,7 @@
<div class="space-y-6"> <div class="space-y-6">
<!-- 初始加载状态 - 只有在完全没有数据时显示 --> <!-- 初始加载状态 - 只有在完全没有数据时显示 -->
<div <div
v-if="loading && links.length === 0 && lists.length === 0" v-if="loading && links.length === 0"
class="flex flex-col items-center justify-center py-12 text-slate-500 dark:text-slate-400" class="flex flex-col items-center justify-center py-12 text-slate-500 dark:text-slate-400"
> >
<i class="fas fa-spinner fa-spin text-2xl mb-3"></i> <i class="fas fa-spinner fa-spin text-2xl mb-3"></i>
@@ -16,7 +16,7 @@
</div> </div>
<!-- 无数据时显示欢迎信息 --> <!-- 无数据时显示欢迎信息 -->
<div <div
v-else-if="links.length === 0 && lists.length === 0" v-else-if="links.length === 0"
class="flex flex-col items-center justify-center py-20 text-center" class="flex flex-col items-center justify-center py-20 text-center"
> >
<img <img
@@ -110,54 +110,22 @@
</div> </div>
</a> </a>
</div> </div>
<!-- Lists 区域 -->
<div v-if="lists.length > 0" class="space-y-6 mt-6">
<section v-for="section in lists" :key="section.title">
<div class="flex items-center justify-between">
<h3
class="text-lg font-semibold text-slate-900 dark:text-slate-200"
>
{{ section.title }}
</h3>
</div>
<div class="mt-3 grid gap-4 app-grid">
<AppCard
v-for="app in section.apps"
:key="app.pkgname"
:app="app"
@open-detail="handleOpenDetail(app)"
/>
</div>
</section>
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import AppCard from "./AppCard.vue";
import { APM_STORE_BASE_URL } from "../global/storeConfig"; import { APM_STORE_BASE_URL } from "../global/storeConfig";
import { reactive } from "vue"; import { reactive } from "vue";
import type { HomeLink, HomeList, App } from "../global/typedefinition"; import type { HomeLink } from "../global/typedefinition";
defineProps<{ defineProps<{
links: HomeLink[]; links: HomeLink[];
lists: HomeList[];
loading: boolean; loading: boolean;
error: string; error: string;
storeFilter?: "spark" | "apm" | "both"; storeFilter?: "spark" | "apm" | "both";
}>(); }>();
const emit = defineEmits<{
(e: "open-detail", app: App | Record<string, unknown>): void;
}>();
// 处理应用卡片点击,添加来自首页的标记
const handleOpenDetail = (app: App) => {
emit("open-detail", { ...app, _fromHomeView: true });
};
// 图片加载状态跟踪 // 图片加载状态跟踪
const imageLoaded = reactive<Record<string, boolean>>({}); const imageLoaded = reactive<Record<string, boolean>>({});
@@ -203,16 +171,4 @@ const onLinkClick = (link: HomeLink) => {
gap: 0.75rem; gap: 0.75rem;
} }
} }
/* 应用卡片网格 - 保持原来的样式 */
.app-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
@media (max-width: 640px) {
.app-grid {
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}
}
</style> </style>
+1 -6
View File
@@ -237,16 +237,11 @@ export interface HomeLink {
[k: string]: unknown; [k: string]: unknown;
} }
export interface HomeList {
title: string;
apps: App[];
}
export interface SidebarEntry { export interface SidebarEntry {
id: string; id: string;
name: string; name: string;
icon?: string; icon?: string;
type?: "category" | "search" | "link"; type?: "category" | "search" | "link" | "homeList";
value?: string; value?: string;
} }