Files
spark-store/src/App.vue
T
2026-08-07 18:23:35 +08:00

3500 lines
112 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<SubmitterWindow v-if="isSubmitterView" />
<div
v-else
class="flex h-screen flex-col overflow-hidden bg-slate-50 text-slate-900 transition-colors duration-300 dark:bg-slate-950 dark:text-slate-100"
>
<WindowTitleBar
:search-query="searchQuery"
@update:search-query="handleSearchInput"
@search-focus="handleSearchFocus"
@open-install-settings="handleOpenInstallSettings"
@open-about="openAboutModal"
@toggle-sidebar="isSidebarOpen = !isSidebarOpen"
@spk-link="handleSpkLink"
/>
<div class="flex min-h-0 flex-1 flex-col lg:flex-row">
<!-- 移动端侧边栏遮罩 -->
<div
v-if="isSidebarOpen"
class="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm lg:hidden"
@click="isSidebarOpen = false"
></div>
<aside
class="fixed top-10 bottom-0 left-0 z-50 w-64 shrink-0 transform border-r border-slate-200/70 bg-white px-4 py-6 transition-transform duration-300 ease-in-out dark:border-slate-800/70 dark:bg-slate-900 lg:sticky lg:top-10 lg:flex lg:h-[calc(100vh-2.5rem)] lg:translate-x-0 lg:flex-col lg:border-b-0"
:class="
isSidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
"
>
<AppSidebar
:active-tab="activeTab"
:category-counts="categoryCounts"
:theme-mode="themeMode"
:spark-available="sparkAvailable"
:apm-available="apmAvailable"
:store-filter="storeFilter"
:sidebar-entries="sidebarEntries"
:entry-counts="entryCounts"
:current-user="currentUser"
@toggle-theme="toggleTheme"
@select-tab="selectTab"
@close="isSidebarOpen = false"
@list="handleList"
@update="handleUpdate"
@submit="handleSubmit"
@request-login="showLoginModal = true"
@open-user-management="openUserManagement"
@open-favorites="openFavoriteManagement"
@open-forum="openExternalUrl(FLARUM_BASE_URL)"
@edit-profile="openExternalUrl(FLARUM_SETTINGS_URL)"
@logout="handleLogout"
/>
</aside>
<main class="flex h-full min-h-0 flex-1 flex-col">
<CategoryBar
v-if="
currentView === 'default' &&
activeTab !== 'home' &&
Object.keys(displayCategories).length > 0
"
class="shrink-0"
:categories="displayCategories"
:selected-category="selectedCategory"
:category-counts="categoryCounts"
@select-category="selectSubCategory"
/>
<div
class="flex min-h-0 flex-1 flex-col overflow-hidden px-4 py-6 lg:px-10"
>
<FavoriteFolderManager
v-if="currentView === 'favorites'"
:folders="favoriteFolders"
:active-folder-id="activeFavoriteFolderId"
:items="resolvedFavoriteItems"
:loading="favoriteLoading"
:error="favoriteError"
@select-folder="selectFavoriteFolder"
@create-folder="createFavoriteFolderFromPrompt"
@remove-selected="removeSelectedFavorites"
@install-selected="installResolvedFavorites"
@open-detail="openDetail"
/>
<template v-else-if="activeTab === 'home'">
<div class="h-full min-h-0 overflow-hidden">
<HomeView
:links="homeLinks"
:loading="homeLoading"
:error="homeError"
:store-filter="storeFilter"
@open-detail="handleAppCardOpenDetail"
@select-section="selectTab"
/>
</div>
</template>
<template v-else-if="activeTab === 'ranking'">
<div class="h-full min-h-0 overflow-hidden">
<RankingView
:apps="apps"
:apm-ranking="apmRanking"
:spark-ranking="sparkRanking"
:ranking-loading="rankingLoading"
:store-filter="storeFilter"
@open-detail="handleAppCardOpenDetail"
/>
</div>
</template>
<template v-else-if="activeTab === 'honor'">
<div class="h-full min-h-0 overflow-hidden">
<HonorView
:apps="apps"
:store-filter="storeFilter"
@open-detail="handleAppCardOpenDetail"
/>
</div>
</template>
<template v-else>
<div class="min-h-0 flex-1">
<AppGrid
:apps="filteredApps"
:loading="effectiveLoading"
:scroll-key="activeTab + '-' + selectedCategory"
:store-filter="storeFilter"
:show-origin="storeFilter === 'both' && !isHomeListTab"
@open-detail="handleAppCardOpenDetail"
/>
</div>
</template>
</div>
</main>
</div>
<AppDetailModal
data-app-modal="detail"
:show="showModal"
:app="currentApp"
:screenshots="screenshots"
:spark-installed="currentAppSparkInstalled"
:apm-installed="currentAppApmInstalled"
:logged-in="isLoggedIn"
:review-app-key="currentReviewAppKey"
:review-tags="currentReviewTags"
:favorited="currentFavoriteMetadata.favorited"
:favorite-folder-name="currentFavoriteMetadata.folderName"
@select-origin="selectDetailOrigin"
@close="closeDetail"
@install="onDetailInstall"
@remove="onDetailRemove"
@favorite="onDetailFavorite"
@request-login="handleDetailRequestLogin"
@open-preview="openScreenPreview"
@open-app="openDownloadedApp"
@check-install="checkAppInstalled"
@show-user="openReviewUserProfile"
/>
<ScreenPreview
:show="showPreview"
:screenshots="screenshots"
:current-screen-index="currentScreenIndex"
@close="closeScreenPreview"
@prev="prevScreen"
@next="nextScreen"
/>
<DownloadQueue
:downloads="downloads"
@pause="pauseDownload"
@resume="resumeDownload"
@cancel="cancelDownload"
@retry="retryDownload"
@clear-completed="clearCompletedDownloads"
@show-detail="showDownloadDetailModalFunc"
/>
<DownloadDetail
:show="showDownloadDetailModal"
:download="currentDownload"
@close="closeDownloadDetail"
@pause="pauseDownload"
@resume="resumeDownload"
@cancel="cancelDownload"
@retry="retryDownload"
@open-app="openDownloadedApp"
/>
<InstalledAppsModal
:show="showInstalledModal"
:apps="installedApps"
:loading="installedLoading"
:error="installedError"
:warning="installedWarning"
:logged-in="isLoggedIn"
:syncing="syncLoading"
:sync-message="syncStatusMessage"
@close="closeInstalledModal"
@refresh="refreshInstalledApps"
@open-app="openDownloadedApp($event.pkgname, $event.origin)"
@open-detail="openDetailFromInstalled"
@uninstall="uninstallInstalledApp"
@sync-to-account="syncInstalledAppsToAccount"
@restore-from-account="openRestoreFromAccount"
@request-login="requireLogin('云端同步需要登录星火账号。')"
/>
<AppListRestoreModal
:show="showRestoreModal"
:loading="restoreLoading"
:error="restoreError"
:items="restoreItems"
:installed-keys="installedCloudKeys"
:installed-package-keys="installedCloudPackageKeys"
@close="showRestoreModal = false"
@install-selected="installCloudItems"
/>
<UpdateCenterModal
:show="updateCenterStore.isOpen.value"
:store="updateCenterStore"
@update:search-query="updateCenterStore.searchQuery.value = $event"
@toggle-selection="updateCenterStore.toggleSelection"
@request-start-selected="handleStartSelectedUpdates"
@confirm-migration-start="confirmMigrationStart"
@dismiss-migration-confirm="
updateCenterStore.showMigrationConfirm.value = false
"
@confirm-close="updateCenterStore.closeNow()"
@dismiss-close-confirm="updateCenterStore.showCloseConfirm.value = false"
/>
<UninstallConfirmModal
:show="showUninstallModal"
:app="uninstallTargetApp"
@close="closeUninstallModal"
@success="onUninstallSuccess"
/>
<ApmInstallConfirmModal
:show="showApmInstallDialog"
@close="closeApmInstallDialog"
@confirm="confirmApmInstall"
/>
<AboutModal :show="showAboutModal" @close="closeAboutModal" />
<SettingsModal :show="showSettingsModal" @close="closeSettingsModal" />
<LoginModal
:show="showLoginModal"
:loading="loginLoading"
:error="loginError"
@close="showLoginModal = false"
@login="handleFlarumLogin"
@register="openExternalUrl(FLARUM_REGISTER_URL)"
/>
<LoginPromptModal
:show="showLoginPrompt"
:message="loginPromptMessage"
@close="showLoginPrompt = false"
@login="openLoginFromPrompt"
@register="openExternalUrl(FLARUM_REGISTER_URL)"
/>
<UserManagementModal
v-if="currentUser"
:show="showUserManagementModal"
:user="currentUser"
:downloaded-apps="downloadedApps"
:sync-enabled="installedSyncEnabled ?? false"
:loading="downloadedLoading"
:error="downloadedError"
:syncing="syncLoading"
:sync-message="syncStatusMessage"
@close="showUserManagementModal = false"
@open-forum="openExternalUrl(FLARUM_BASE_URL)"
@edit-profile="openExternalUrl(FLARUM_SETTINGS_URL)"
@toggle-sync="setInstalledSyncEnabled"
@sync-now="syncInstalledAppsNow"
@refresh-downloads="loadDownloadedHistory"
/>
<ReviewUserProfileModal
:show="showReviewUserProfileModal"
:profile="selectedReviewUserProfile"
@close="showReviewUserProfileModal = false"
@open-forum-profile="openExternalUrl"
/>
<FavoriteFolderSelector
:show="showFavoriteSelector"
:folders="favoriteFolders"
:selected-folder-ids="currentFavoriteFolderIds"
@close="showFavoriteSelector = false"
@save-selection="saveCurrentFavoriteFolders"
@create-folder="createFavoriteFolderFromSelector"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
import axios, { AxiosError } from "axios";
import pino from "pino";
import AppSidebar from "./components/AppSidebar.vue";
import AppGrid from "./components/AppGrid.vue";
import HomeView from "./components/HomeView.vue";
import RankingView from "./components/RankingView.vue";
import HonorView from "./components/HonorView.vue";
import CategoryBar from "./components/CategoryBar.vue";
import AppDetailModal from "./components/AppDetailModal.vue";
import ScreenPreview from "./components/ScreenPreview.vue";
import DownloadQueue from "./components/DownloadQueue.vue";
import DownloadDetail from "./components/DownloadDetail.vue";
import InstalledAppsModal from "./components/InstalledAppsModal.vue";
import AppListRestoreModal from "./components/AppListRestoreModal.vue";
import UpdateCenterModal from "./components/UpdateCenterModal.vue";
import UninstallConfirmModal from "./components/UninstallConfirmModal.vue";
import ApmInstallConfirmModal from "./components/ApmInstallConfirmModal.vue";
import AboutModal from "./components/AboutModal.vue";
import SettingsModal from "./components/SettingsModal.vue";
import LoginModal from "./components/LoginModal.vue";
import LoginPromptModal from "./components/LoginPromptModal.vue";
import FavoriteFolderSelector from "./components/FavoriteFolderSelector.vue";
import FavoriteFolderManager from "./components/FavoriteFolderManager.vue";
import UserManagementModal from "./components/UserManagementModal.vue";
import ReviewUserProfileModal from "./components/ReviewUserProfileModal.vue";
import WindowTitleBar from "./components/WindowTitleBar.vue";
import SubmitterWindow from "./components/SubmitterWindow.vue";
import {
APM_STORE_BASE_URL,
FLARUM_BASE_URL,
FLARUM_REGISTER_URL,
FLARUM_SETTINGS_URL,
currentApp,
currentAppSparkInstalled,
currentAppApmInstalled,
currentStoreMode,
showApmInstallDialog,
getHybridDefaultOrigin,
loadPriorityConfig,
} from "./global/storeConfig";
import {
downloads,
removeDownloadItem,
watchDownloadsChange,
} from "./global/downloadStatus";
import {
installedSyncEnabled,
loadInstalledSyncPreference,
setInstalledSyncEnabled,
} from "./global/accountSyncState";
import {
countSearchMatchesByCategory,
rankAppsBySearch,
} from "./modules/appSearch";
import { handleInstall, handleRetry } from "./modules/processInstall";
import {
addFavoriteItem,
bulkDeleteFavoriteItems,
createFavoriteFolder,
deleteFavoriteItem,
exchangeFlarumToken,
fetchSyncedAppList,
listDownloadedApps,
listFavoriteFolders,
listFavoriteItems,
recordDownloadedApp,
uploadSyncedAppList,
} from "./modules/backendApi";
import { requestFlarumToken } from "./modules/flarumAuth";
import {
currentUser,
isLoggedIn,
logout,
setAuthSession,
} from "./global/authState";
import {
getEffectiveStoreFilter,
isOriginEnabled,
isOriginUsable,
} from "./modules/storeFilter";
import { createUpdateCenterStore } from "./modules/updateCenter";
import {
buildReviewAppKey,
buildFavoriteAppKey,
buildReviewTags,
getDisplayApp,
parsePackageArch,
} from "./modules/appIdentity";
import { resolveFavoriteItems } from "./modules/favoriteAvailability";
import {
buildSyncItems,
cloudItemKey,
cloudPackageKey,
mergeInstalledApps,
resolveCloudInstallCandidate,
} from "./modules/appListSync";
import type {
App,
AppJson,
DownloadItem,
DownloadResult,
ChannelPayload,
CategoryInfo,
HomeLink,
FlarumLoginPayload,
SidebarEntry,
UpdateCenterItem,
ReviewTags,
FavoriteFolder,
FavoriteItem,
InstalledAppInfo,
ResolvedFavoriteItem,
SystemInfo,
DownloadedAppRecord,
SyncedAppListItem,
AppReview,
ReviewUserProfile,
} from "./global/typedefinition";
import type { Ref } from "vue";
import type { IpcRendererEvent } from "electron";
const logger = pino();
// Axios 全局配置
const axiosInstance = axios.create({
baseURL: APM_STORE_BASE_URL,
timeout: 5000, // 增加到 5 秒,避免网络波动导致的超时
});
const fetchWithRetry = async <T,>(
url: string,
signal?: AbortSignal,
retries = 3,
delay = 1000,
): Promise<T> => {
try {
const response = await axiosInstance.get<T>(url, { signal });
return response.data;
} catch (error) {
// 请求被取消(AbortSignal)时直接抛出,不再重试
if (signal?.aborted) throw error;
const axiosError = error as AxiosError;
const status = axiosError.response?.status;
// 仅对网络错误(无响应)、服务端 5xx 错误或超时进行重试;
// 4xx(如 404/400)属于明确的客户端错误,直接抛出以避免无谓重试。
const isNetworkError = status === undefined;
const isServerError = typeof status === "number" && status >= 500;
const isTimeout =
axiosError.code === "ECONNABORTED" || axiosError.code === "ETIMEDOUT";
if (retries > 0 && (isNetworkError || isServerError || isTimeout)) {
// 若已取消则提前退出,避免对已卸载组件发起新请求
if (signal?.aborted) throw error;
await new Promise((resolve) => setTimeout(resolve, delay));
return fetchWithRetry(url, signal, retries - 1, delay * 2);
}
throw error;
}
};
// 根级请求取消控制器:组件卸载时 abort,避免对已卸载组件发起/重试请求
const rootAbortController = new AbortController();
// 渲染进程从 IPC 拿到的 result.apps 实际类型为 anyipcRenderer.invoke 返回 Promise<any>),
// 直接断言成 InstalledAppInfo[] 会绕过运行时类型检查。后端字段缺失时会引发运行时错误。
// 此守卫仅校验本项目实际使用的关键字段,后端字段缺失时跳过即可,避免整批失败。
const isInstalledAppInfo = (value: unknown): value is InstalledAppInfo => {
if (typeof value !== "object" || value === null) return false;
const v = value as Partial<InstalledAppInfo>;
return (
typeof v.pkgname === "string" &&
typeof v.name === "string" &&
typeof v.version === "string" &&
typeof v.arch === "string" &&
(v.origin === "spark" || v.origin === "apm") &&
typeof v.flags === "string" &&
typeof v.isDependency === "boolean" &&
// icon 在类型中为可选字段(string | undefined),需显式校验其类型,
// 避免非字符串值进入下游 app.icon || "" 触发隐式转换异常
(typeof v.icon === "string" || v.icon === undefined)
);
};
// 单来源已安装查询超时时间:某个来源(如 APM)响应极慢或挂起时,
// 不应阻塞其它来源整体返回,超时后该来源标记为失败并走 warning/error 流程。
const LIST_INSTALLED_TIMEOUT_MS = 15000;
// 为 Promise 增加超时控制:超时即 reject,配合 Promise.allSettled 让单来源失败不影响其它来源。
const withTimeout = <T,>(
promise: Promise<T>,
ms: number,
label: string,
): Promise<T> => {
return Promise.race([
promise,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error(`${label} 超时(${ms}ms`)), ms),
),
]);
};
// 响应式状态
const themeMode = ref<"light" | "dark" | "auto">("auto");
const systemIsDark = ref(
window.matchMedia("(prefers-color-scheme: dark)").matches,
);
const isDarkTheme = computed(() => {
if (themeMode.value === "auto") return systemIsDark.value;
return themeMode.value === "dark";
});
const isSubmitterView = ref(false);
const categories: Ref<Record<string, CategoryInfo>> = ref({});
const apps: Ref<App[]> = ref([]);
const tabCategories: Ref<Record<string, Record<string, CategoryInfo>>> = ref(
{},
);
const tabApps: Ref<Record<string, App[]>> = ref({});
// 正在加载中的入口 ID 集合,用于显示骨架屏并防止重复加载
const loadingTabs = ref<Set<string>>(new Set());
// 首页推荐列表入口对应的各来源 jsonUrl{ [entryId]: { spark?, apm? } }
const homeListUrls = ref<Record<string, { spark?: string; apm?: string }>>({});
const activeTab = ref("home");
type MainView = "default" | "favorites";
const currentView = ref<MainView>("default");
const selectedCategory = ref("all");
const searchQuery = ref("");
const isSidebarOpen = ref(false);
const showModal = ref(false);
const showPreview = ref(false);
const currentScreenIndex = ref(0);
const screenshots = ref<string[]>([]);
const loading = ref(true);
const showDownloadDetailModal = ref(false);
const currentDownload: Ref<DownloadItem | null> = ref(null);
const showInstalledModal = ref(false);
const installedApps = ref<App[]>([]);
const installedLoading = ref(false);
const installedError = ref("");
// 部分来源失败(如 Spark/APM 之一不可用)时使用更轻量的 warning 提示,避免与已有列表同时呈现红色致命错误条造成 UX 混淆
const installedWarning = ref("");
// refreshInstalledApps 的代次计数器,用于异步竞态防护:新进入一次刷新自增,await 后若代次已变则放弃本轮写入
const installedRefreshGeneration = ref(0);
const updateCenterStore = createUpdateCenterStore();
const showUninstallModal = ref(false);
const uninstallTargetApp: Ref<App | null> = ref(null);
const showAboutModal = ref(false);
const showSettingsModal = ref(false);
const showLoginModal = ref(false);
const loginLoading = ref(false);
const loginError = ref("");
const showLoginPrompt = ref(false);
const loginPromptMessage = ref("请登录星火账号后继续操作。");
const showUserManagementModal = ref(false);
const selectedReviewUserProfile = ref<ReviewUserProfile | null>(null);
const showReviewUserProfileModal = ref(false);
const sparkAvailable = ref(false);
const apmAvailable = ref(false);
const sidebarEntries: Ref<SidebarEntry[]> = ref([]);
const favoriteFolders = ref<FavoriteFolder[]>([]);
const activeFavoriteFolderId = ref<number | null>(null);
const favoriteItems = ref<FavoriteItem[]>([]);
const favoriteItemsByFolder = ref<Record<number, FavoriteItem[]>>({});
const showFavoriteSelector = ref(false);
const favoriteTargetApp = ref<App | null>(null);
const favoriteSelectorDraftFolderIds = ref<Array<number | "default"> | null>(
null,
);
const favoriteLoading = ref(false);
const favoriteError = ref("");
const favoriteRequestGeneration = ref(0);
const downloadedApps = ref<DownloadedAppRecord[]>([]);
const downloadedLoading = ref(false);
const downloadedError = ref("");
const downloadedRequestGeneration = ref(0);
const syncLoading = ref(false);
const syncStatusMessage = ref("");
const syncRequestGeneration = ref(0);
const syncCandidateApps = ref<App[]>([]);
const restoreLoading = ref(false);
const restoreError = ref("");
const showRestoreModal = ref(false);
const restoreItems = ref<SyncedAppListItem[]>([]);
const restoreRequestGeneration = ref(0);
const installedSyncPromptShown = ref(false);
const systemInfo = ref<SystemInfo>({ distro: "unknown" });
type PendingDownloadRecord = Omit<
DownloadedAppRecord,
"id" | "downloadedAt"
> & {
userId: number;
};
const pendingDownloadRecords = new Map<number, PendingDownloadRecord>();
/** 启动参数 --no-apm => 仅 Spark--no-spark => 仅 APM;由主进程 IPC 提供 */
const storeFilter = ref<"spark" | "apm" | "both">("both");
const availableSources = computed(() => ({
spark: sparkAvailable.value,
apm: apmAvailable.value,
}));
const effectiveStoreFilter = computed(() =>
getEffectiveStoreFilter(storeFilter.value, availableSources.value),
);
// 计算属性
const baseApps = computed(() => {
let result = [...apps.value];
// 合并相同包名的应用 (混合模式)
if (currentStoreMode.value === "hybrid") {
const mergedMap = new Map<string, App>();
for (const app of result) {
const existing = mergedMap.get(app.pkgname);
if (existing) {
if (!existing.isMerged) {
existing.isMerged = true;
// 根据当前的 origin 分配到对应的属性
if (existing.origin === "spark") existing.sparkApp = { ...existing };
else if (existing.origin === "apm") existing.apmApp = { ...existing };
}
if (app.origin === "spark") existing.sparkApp = app;
else if (app.origin === "apm") existing.apmApp = app;
} else {
mergedMap.set(app.pkgname, { ...app });
}
}
result = Array.from(mergedMap.values());
}
return result;
});
const displayCategories = computed(() => {
if (activeTab.value === "all") return categories.value;
return tabCategories.value[activeTab.value] || {};
});
const displayApps = computed(() => {
if (activeTab.value === "all") return baseApps.value;
return tabApps.value[activeTab.value] || [];
});
// 当前的加载状态:全局加载中,或当前入口正在加载中
const effectiveLoading = computed(
() =>
loading.value ||
(activeTab.value !== "home" &&
activeTab.value !== "all" &&
loadingTabs.value.has(activeTab.value) &&
!tabApps.value[activeTab.value]),
);
const filteredApps = computed(() => {
let result = [...displayApps.value];
const effectiveCategory = getEffectiveCategory();
if (effectiveCategory && effectiveCategory !== "all") {
result = result.filter((app) => app.category === effectiveCategory);
}
if (searchQuery.value.trim()) {
return rankAppsBySearch(result, searchQuery.value);
}
return result;
});
const categoryCounts = computed(() => {
const sourceApps = displayApps.value;
if (searchQuery.value.trim()) {
// all 统计所有应用中的搜索匹配数(不随 tab 变化)
const allCounts = countSearchMatchesByCategory(
baseApps.value,
searchQuery.value,
);
// 各分类统计当前 tab 中的搜索匹配数
const tabCounts = countSearchMatchesByCategory(
sourceApps,
searchQuery.value,
);
return { ...tabCounts, all: allCounts.all };
}
const counts: Record<string, number> = { all: baseApps.value.length };
sourceApps.forEach((app) => {
if (!counts[app.category]) counts[app.category] = 0;
counts[app.category]++;
});
return counts;
});
const entryCounts = computed(() => {
const counts: Record<string, number> = {};
const allApps = baseApps.value;
sidebarEntries.value.forEach((entry) => {
if (entry.type === "category" && entry.value) {
// 优先使用已加载的入口应用总数;未加载时回退到全局分类计数
const tabLen = tabApps.value[entry.id]?.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 {
counts[entry.id] = 0;
}
});
return counts;
});
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 currentReviewAppKey = computed(() => {
if (!currentDisplayApp.value) return "";
return buildReviewAppKey(currentDisplayApp.value, clientArch.value);
});
const currentReviewTags = computed<ReviewTags | null>(() => {
if (!currentDisplayApp.value) return null;
return buildReviewTags(currentDisplayApp.value, {
clientArch: clientArch.value,
distro: systemInfo.value.distro,
});
});
const currentFavoriteMetadata = computed(
(): {
favorited: boolean;
folderName: string;
} => {
const app = currentDisplayApp.value;
if (!app) return { favorited: false, folderName: "" };
const folder = favoriteFolders.value.find((favoriteFolder) => {
const items = favoriteItemsByFolder.value[favoriteFolder.id] ?? [];
return items.some(
(favorite) =>
favorite.pkgname === app.pkgname &&
favorite.category === app.category,
);
});
if (!folder) return { favorited: false, folderName: "" };
return { favorited: true, folderName: folder.name.trim() };
},
);
const currentFavoriteFolderIds = computed((): Array<number | "default"> => {
if (favoriteSelectorDraftFolderIds.value) {
return favoriteSelectorDraftFolderIds.value;
}
const app = favoriteTargetApp.value ?? currentDisplayApp.value;
if (!app) return [];
return favoriteFolders.value
.filter((folder) =>
(favoriteItemsByFolder.value[folder.id] ?? []).some(
(favorite) =>
favorite.pkgname === app.pkgname &&
favorite.category === app.category,
),
)
.map((folder) => folder.id);
});
const resolvedFavoriteItems = computed<ResolvedFavoriteItem[]>(() =>
resolveFavoriteItems(
favoriteItems.value,
apps.value,
installedApps.value,
availableSources.value,
storeFilter.value,
clientArch.value,
),
);
const installedCloudKeys = computed(
() => new Set(installedApps.value.map((app) => cloudItemKey(app))),
);
const installedCloudPackageKeys = computed(
() => new Set(syncCandidateApps.value.map((app) => cloudPackageKey(app))),
);
// 方法
const syncThemePreference = () => {
document.documentElement.classList.toggle("dark", isDarkTheme.value);
};
const initTheme = () => {
const savedTheme = localStorage.getItem("theme");
if (
savedTheme === "dark" ||
savedTheme === "light" ||
savedTheme === "auto"
) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
themeMode.value = savedTheme as any;
} else {
themeMode.value = "auto";
}
window.ipcRenderer.send(
"set-theme-source",
themeMode.value === "auto" ? "system" : themeMode.value,
);
syncThemePreference();
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", (e) => {
systemIsDark.value = e.matches;
});
};
const toggleTheme = () => {
if (themeMode.value === "auto") themeMode.value = "light";
else if (themeMode.value === "light") themeMode.value = "dark";
else themeMode.value = "auto";
};
const selectTab = (tab: string) => {
currentView.value = "default";
activeTab.value = tab;
selectedCategory.value = "all";
isSidebarOpen.value = false;
window.scrollTo({ top: 0, behavior: "smooth" });
if (tab === "home" && homeLinks.value.length === 0) {
loadHome();
}
if (tab !== "home" && tab !== "all") {
const entry = sidebarEntries.value.find((e) => e.id === tab);
if (entry && entry.type === "category") {
loadTabApps(tab);
} else if (entry && entry.type === "homeList") {
loadHomeListApps(tab);
}
}
};
const selectSubCategory = (category: string) => {
currentView.value = "default";
selectedCategory.value = category;
window.scrollTo({ top: 0, behavior: "smooth" });
};
const getEffectiveCategory = (): string => {
if (activeTab.value === "home") return "";
if (activeTab.value === "all") return selectedCategory.value;
const entry = sidebarEntries.value.find((e) => e.id === activeTab.value);
if (entry) {
if (entry.type === "category") return selectedCategory.value;
if (entry.type === "search") {
searchQuery.value = entry.value || "";
return selectedCategory.value;
}
}
return selectedCategory.value;
};
// 从仓库获取应用详细信息的辅助函数
const fetchAppFromStore = async (
pkgname: string,
category: string,
origin: "spark" | "apm",
): Promise<App | null> => {
try {
const arch = window.apm_store.arch || "amd64";
const finalArch = origin === "spark" ? `${arch}-store` : `${arch}-apm`;
// 路径参数需编码,避免特殊字符破坏请求路径或造成路径穿越
const appJsonUrl = `${APM_STORE_BASE_URL}/${finalArch}/${encodeURIComponent(
category,
)}/${encodeURIComponent(pkgname)}/app.json`;
const response = await fetch(appJsonUrl);
if (!response.ok) return null;
const appJson = await response.json();
// img_urls 可能为字符串形式的 JSON,解析失败时安全回退为空数组
const parsedImgUrls = (() => {
if (typeof appJson.img_urls === "string") {
try {
return JSON.parse(appJson.img_urls) as string[];
} catch {
return [];
}
}
return (appJson.img_urls as string[]) || [];
})();
return {
name: appJson.Name || "",
pkgname: appJson.Pkgname || pkgname,
version: appJson.Version || "",
filename: appJson.Filename || "",
torrent_address: appJson.Torrent_address || "",
author: appJson.Author || "",
contributor: appJson.Contributor || "",
website: appJson.Website || "",
update: appJson.Update || "",
size: appJson.Size || "",
more: appJson.More || "",
tags: appJson.Tags || "",
img_urls: parsedImgUrls,
icons: appJson.icons || "",
category: category,
origin: origin,
currentStatus: "not-installed",
};
} catch (e) {
console.warn(`Failed to fetch ${origin} app info for ${pkgname}`, e);
return null;
}
};
// 已安装应用页查看详情:复用所有应用页的合并详情视图(始终展示 APM/Spark 两种包)
const openDetailFromInstalled = (app: App) => {
openDetail({ ...app, _fromInstalled: true });
};
// openDetail 输入类型:完整 App 或仅含必要字段的轻量对象(含内部来源标记)
interface OpenDetailInput extends Partial<App> {
pkgname?: string;
category?: string;
_fromHomeView?: boolean;
_fromInstalled?: boolean;
_fromDeepLink?: boolean;
origin?: "spark" | "apm";
}
// 提取远程/本地均无匹配时的回退 App 构造(两处兜底分支共用,避免重复字段映射)
const createFallbackApp = (
raw: Record<string, unknown>,
pkgname: string,
category: string,
): App => ({
name: (raw.name as string) || "",
pkgname,
version: (raw.version as string) || "",
filename: (raw.filename as string) || "",
category,
torrent_address: "",
author: "",
contributor: "",
website: "",
update: "",
size: "",
more: (raw.more as string) || "",
tags: "",
img_urls: [],
icons: "",
origin: (raw.origin as "spark" | "apm") || "apm",
currentStatus: "not-installed",
});
const openDetail = async (app: App | OpenDetailInput) => {
// 提取 pkgname 和 category(必须存在)
const pkgname = app?.pkgname;
if (!pkgname) {
console.warn("openDetail called without pkgname");
return;
}
const category = app.category || "unknown";
// 检查是否来自 HomeView 或 DeepLink(需要重新获取完整信息)
// 内部来源标记仅存在于轻量输入对象上,按 OpenDetailInput 读取以兼容联合类型
const marker = app as OpenDetailInput;
const fromHomeView = marker._fromHomeView === true;
const fromDeepLink = marker._fromDeepLink === true;
// 已安装应用页:始终按"所有应用页"的方式双来源拉取并合并展示(不移除另一类型)
const fromInstalled = marker._fromInstalled === true;
const needFetchFromStore = fromHomeView || fromDeepLink || fromInstalled;
// 首先尝试从当前已经处理好(合并/筛选)的 filteredApps 中查找
// 优先匹配点击来源 origin(例如排行点击 APM 应用时不应误匹配到 Spark 版)
const clickedOrigin = app.origin as "spark" | "apm" | undefined;
let fullApp = filteredApps.value.find(
(a) =>
a.pkgname === pkgname && (!clickedOrigin || a.origin === clickedOrigin),
);
// 如果没找到,回退到全局 apps 中查找(同样优先 origin
if (!fullApp) {
fullApp = apps.value.find(
(a) =>
a.pkgname === pkgname && (!clickedOrigin || a.origin === clickedOrigin),
);
}
// 仍无匹配则退化为仅按 pkgname 匹配(兼容无 origin 的场景,如搜索结果)
if (!fullApp) {
fullApp =
filteredApps.value.find((a) => a.pkgname === pkgname) ||
apps.value.find((a) => a.pkgname === pkgname);
}
let finalApp: App;
// 来自 HomeView 或 DeepLink 的应用需要重新从仓库获取完整信息
if (needFetchFromStore) {
// 从 Spark 和 APM 仓库获取完整的应用信息
// 已安装页忽略当前商店单一模式限制,始终尝试拉取两种来源,保证另一类型不被隐藏
let [sparkApp, apmApp] = await Promise.all([
fromInstalled || storeFilter.value !== "apm"
? fetchAppFromStore(pkgname, category, "spark")
: Promise.resolve(null),
fromInstalled || storeFilter.value !== "spark"
? fetchAppFromStore(pkgname, category, "apm")
: Promise.resolve(null),
]);
// 已安装页:若某来源仓库拉取失败(如分类不匹配),用本地已合并的应用补全,避免丢失另一类型
if (fromInstalled && fullApp && fullApp.isMerged) {
const merged = fullApp as App;
if (!sparkApp && merged.sparkApp) sparkApp = merged.sparkApp;
if (!apmApp && merged.apmApp) apmApp = merged.apmApp;
}
// 构建合并的应用对象
if (sparkApp || apmApp) {
// 如果两个仓库都有这个应用,创建合并对象
if (sparkApp && apmApp) {
// 优先遵从点击来源 origin;无点击来源时再按优先级配置决定默认显示
const defaultOrigin =
clickedOrigin && (clickedOrigin === "spark" ? sparkApp : apmApp)
? clickedOrigin
: getHybridDefaultOrigin(sparkApp);
finalApp = {
...(defaultOrigin === "spark" ? sparkApp : apmApp), // 根据优先级选择主显示
isMerged: true,
sparkApp: sparkApp,
apmApp: apmApp,
viewingOrigin: defaultOrigin, // 默认查看来源版本
};
} else if (sparkApp) {
finalApp = sparkApp;
} else {
finalApp = apmApp!;
}
} else if (fullApp) {
finalApp = fullApp;
} else {
// 两个仓库都没有找到,且本地也没有,构造一个最小可用的 App 对象
finalApp = createFallbackApp(
app as Record<string, unknown>,
pkgname,
category,
);
}
} else {
// 非 HomeView 来源,使用原来的逻辑
if (fullApp) {
finalApp = fullApp;
} else {
// 构造一个最小可用的 App 对象
finalApp = createFallbackApp(
app as Record<string, unknown>,
pkgname,
category,
);
}
}
// 检查 Spark/APM 安装状态,已安装的版本优先展示
if (finalApp.isMerged && (finalApp.sparkApp || finalApp.apmApp)) {
const [sparkInstalled, apmInstalled] = await Promise.all([
finalApp.sparkApp
? (window.ipcRenderer.invoke("check-installed", {
pkgname: finalApp.sparkApp.pkgname,
origin: "spark",
}) as Promise<boolean>)
: Promise.resolve(false),
finalApp.apmApp
? (window.ipcRenderer.invoke("check-installed", {
pkgname: finalApp.apmApp.pkgname,
origin: "apm",
}) as Promise<boolean>)
: Promise.resolve(false),
]);
// 来源默认展示规则:
// 1) 已安装页打开:按安装类型打开。
// - 仅一个来源已安装 → 强制展示该已装来源(无需策略覆盖)
// - 多个来源已安装 → 按「设置的优先标签」打开(策略)
// 2) 其他页面:一律按「设置的优先标签」打开(策略 > 服务端混合默认),不强制。
// 注:forceViewingOrigin 仅用于「已安装页 + 唯一安装来源」这一显式安装类型场景,
// 其余情况都不强制,交由详情页按用户标签策略重算。
let forceOrigin: "spark" | "apm" | undefined = undefined;
if (fromInstalled) {
const installedOrigins = (app as Record<string, unknown>).origins as
| Array<"spark" | "apm">
| undefined;
if (installedOrigins && installedOrigins.length === 1) {
// 单来源安装:按安装类型强制
forceOrigin = installedOrigins[0];
} else if (installedOrigins && installedOrigins.length > 1) {
// 多来源安装:交由策略(按设置优先标签),不强制
forceOrigin = undefined;
} else {
// origins 未随事件携带时,用 IPC 检测结果兜底判断安装类型
if (sparkInstalled && !apmInstalled) forceOrigin = "spark";
else if (apmInstalled && !sparkInstalled) forceOrigin = "apm";
}
}
// 非强制分支:清除可能由「已安装页」入口遗留的 forceViewingOrigin 粘性标志,
// 避免同一应用从其他页面再次打开时被锁死在旧来源(表现为设置切换不生效)。
finalApp.forceViewingOrigin = false;
if (forceOrigin && (forceOrigin === "spark" ? finalApp.sparkApp : finalApp.apmApp)) {
// 已安装页 + 唯一安装来源 → 强制展示该安装类型,优先级高于用户标签策略
finalApp.viewingOrigin = forceOrigin;
finalApp.forceViewingOrigin = true;
} else if (sparkInstalled && !apmInstalled) {
// 仅 Spark 安装(其他页面):默认回退展示已装版本,不强制(仍受用户策略覆盖)
finalApp.viewingOrigin = "spark";
} else if (apmInstalled && !sparkInstalled) {
finalApp.viewingOrigin = "apm";
} else {
// 都安装/都未安装且未指定来源:交由「标签优先显示策略」决定默认展示。
// 此处仅写入混合默认作为回退(供 appIdentity / 截图等下游使用),
// 但不置 forceViewingOrigin,详情页会按用户策略重算。
finalApp.viewingOrigin = getHybridDefaultOrigin(
finalApp.sparkApp || finalApp,
);
}
}
const displayAppForScreenshots =
finalApp.viewingOrigin !== undefined && finalApp.isMerged
? ((finalApp.viewingOrigin === "spark"
? finalApp.sparkApp
: finalApp.apmApp) ?? finalApp)
: finalApp;
currentApp.value = finalApp;
currentScreenIndex.value = 0;
loadScreenshots(displayAppForScreenshots);
showModal.value = true;
currentAppSparkInstalled.value = false;
currentAppApmInstalled.value = false;
checkAppInstalled(finalApp);
if (
isLoggedIn.value &&
favoriteFolders.value.length === 0 &&
!favoriteLoading.value
) {
void loadFavoriteMetadataForDetail();
}
nextTick(() => {
const modal = document.querySelector(
'[data-app-modal="detail"] [data-testid="detail-scroll-content"]',
);
if (modal) modal.scrollTop = 0;
});
};
const checkAppInstalled = (app: App) => {
if (app.isMerged) {
if (app.sparkApp) {
window.ipcRenderer
.invoke("check-installed", {
pkgname: app.sparkApp.pkgname,
origin: "spark",
})
.then((isInstalled: boolean) => {
currentAppSparkInstalled.value = isInstalled;
});
}
if (app.apmApp) {
window.ipcRenderer
.invoke("check-installed", {
pkgname: app.apmApp.pkgname,
origin: "apm",
})
.then((isInstalled: boolean) => {
currentAppApmInstalled.value = isInstalled;
});
}
} else {
window.ipcRenderer
.invoke("check-installed", { pkgname: app.pkgname, origin: app.origin })
.then((isInstalled: boolean) => {
if (app.origin === "spark") {
currentAppSparkInstalled.value = isInstalled;
} else {
currentAppApmInstalled.value = isInstalled;
}
});
}
};
const loadScreenshots = (app: App) => {
screenshots.value = [];
const arch = window.apm_store.arch || "amd64";
const finalArch = app.origin === "spark" ? `${arch}-store` : `${arch}-apm`;
for (let i = 1; i <= 5; i++) {
const screenshotUrl = `${APM_STORE_BASE_URL}/${finalArch}/${app.category}/${app.pkgname}/screen_${i}.png`;
screenshots.value.push(screenshotUrl);
}
};
const closeDetail = () => {
showModal.value = false;
currentApp.value = null;
};
const openScreenPreview = (index: number) => {
currentScreenIndex.value = index;
showPreview.value = true;
};
const closeScreenPreview = () => {
showPreview.value = false;
};
// Home data
const homeLinks = ref<HomeLink[]>([]);
const homeLoading = ref(false);
const homeError = ref("");
// 首页三区域:区域2 板块(已迁移至「全部应用」等分类入口,首页不再展示)
const apmRanking = ref<App[]>([]);
const sparkRanking = ref<App[]>([]);
// 区域3 · 下载排行(全站):从全量应用目录逐应用拉取 download-times.txt
// 按 origin 分别排 APM / Spark 两榜。用代次守卫避免竞态。
// 优化:localStorage 缓存(1 小时 TTL)避免重复请求;并发 48;每个批次完成后立即发布
// 增量排名,边拉边显;fetchDownloadCount 优先命中缓存。
let rankingGeneration = 0;
const rankingLoading = ref(false);
const DOWNLOAD_COUNT_CACHE_KEY = "spark-store:download-counts:v1";
const DOWNLOAD_COUNT_CACHE_TTL_MS = 60 * 60 * 1000; // 1 小时
interface CachedCount {
count: number;
ts: number;
}
const downloadCountCache = new Map<string, CachedCount>();
const cacheKey = (app: App) => `${app.origin}:${app.category}:${app.pkgname}`;
const loadCacheFromStorage = () => {
try {
const raw = localStorage.getItem(DOWNLOAD_COUNT_CACHE_KEY);
if (!raw) return;
const data = JSON.parse(raw) as Record<string, CachedCount>;
const now = Date.now();
for (const [k, v] of Object.entries(data)) {
if (now - v.ts < DOWNLOAD_COUNT_CACHE_TTL_MS) {
downloadCountCache.set(k, v);
}
}
} catch {
// ignore corrupted cache
}
};
const saveCacheToStorage = () => {
try {
const obj: Record<string, CachedCount> = {};
downloadCountCache.forEach((v, k) => {
obj[k] = v;
});
localStorage.setItem(DOWNLOAD_COUNT_CACHE_KEY, JSON.stringify(obj));
} catch {
// ignore quota errors
}
};
const fetchDownloadCount = async (app: App): Promise<number> => {
const key = cacheKey(app);
const cached = downloadCountCache.get(key);
if (cached) return cached.count;
const arch = window.apm_store.arch || "amd64";
const finalArch = app.origin === "spark" ? `${arch}-store` : `${arch}-apm`;
try {
const resp = await fetch(
`${APM_STORE_BASE_URL}/${finalArch}/${app.category}/${app.pkgname}/download-times.txt`,
);
if (!resp.ok) return 0;
const text = (await resp.text()).trim();
const n = parseInt(text, 10);
const count = Number.isFinite(n) ? n : 0;
downloadCountCache.set(key, { count, ts: Date.now() });
return count;
} catch {
return 0;
}
};
const publishRanking = (results: App[]) => {
apmRanking.value = results
.filter((a) => a.origin === "apm")
.sort((x, y) => (y.downloadCount || 0) - (x.downloadCount || 0))
.slice(0, 10);
sparkRanking.value = results
.filter((a) => a.origin === "spark")
.sort((x, y) => (y.downloadCount || 0) - (x.downloadCount || 0))
.slice(0, 10);
};
const loadRanking = async () => {
if (apps.value.length === 0) return;
const gen = ++rankingGeneration;
rankingLoading.value = true;
const all = apps.value.slice();
const CONCURRENCY = 15;
const results: App[] = [];
for (let i = 0; i < all.length; i += CONCURRENCY) {
if (gen !== rankingGeneration) {
saveCacheToStorage();
return;
}
const batch = all.slice(i, i + CONCURRENCY);
const settled = await Promise.all(
batch.map(async (app) => ({
...app,
downloadCount: await fetchDownloadCount(app),
})),
);
results.push(...settled);
// 边拉边发:每批完成后立即发布增量排名
if (gen === rankingGeneration) publishRanking(results);
}
if (gen !== rankingGeneration) {
saveCacheToStorage();
return;
}
rankingLoading.value = false;
saveCacheToStorage();
};
// 启动时即加载本地缓存(无需等待 apps),二次启动首屏即可见缓存排行
loadCacheFromStorage();
// 排行榜在 loadApps 全量完成后(onMounted)触发一次,确保 spark/apm 应用均已就绪
const loadHome = async () => {
homeLoading.value = true;
homeError.value = "";
homeLinks.value = [];
try {
const arch = window.apm_store.arch || "amd64";
const modes: Array<"spark" | "apm"> =
storeFilter.value === "both" ? ["spark", "apm"] : [storeFilter.value];
// 按名称去重,spark 优先:同名链接 spark 覆盖 apm
const seenNames = new Set<string>();
// 并行请求各来源的 homelinks.json,缩短首页加载耗时
const modeResults = await Promise.all(
modes.map(async (mode) => {
const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`;
const base = `${APM_STORE_BASE_URL}/${finalArch}/home`;
try {
const res = await fetch(`${base}/homelinks.json`);
if (res.ok) return { mode, raw: (await res.json()) as unknown };
} catch (e) {
console.warn(`Failed to load ${mode} homelinks.json`, e);
}
return { mode, raw: undefined };
}),
);
for (const { mode, raw } of modeResults) {
if (!raw) continue;
// 校验 links 为数组,且每项均为对象(避免后端返回异常结构导致运行时错误)
const links = Array.isArray(raw)
? (raw.filter((x) => x && typeof x === "object") as Record<
string,
unknown
>[])
: [];
for (const l of links) {
const name = (l.Name as string) || (l.name as string) || "";
if (!name) continue; // 跳过空名称,避免空字符串污染 seenNames 与去重逻辑
if (seenNames.has(name)) continue; // 已由更高优先级来源(spark)占据
// 仅校验 url 必需;远程 homelinks.json 不含 icon 字段(图片由 imgUrl 提供),
// 故 icon 不作为硬性校验,缺省为空串以兼容 HomeLink 类型。
const url = (l.Url as string) || (l.url as string) || "";
if (!url) continue;
const icon = (l.Icon as string) || (l.icon as string) || "";
seenNames.add(name);
// 显式提取已知字段构造,避免通过展开运算符 { ...l } 把远程不可信数据中的未知属性注入响应式状态
const safeLink: HomeLink = {
name,
url,
icon,
more: (l.more as string) || undefined,
imgUrl: (l.imgUrl as string) || undefined,
type: (l.type as string) || undefined,
origin: mode,
};
homeLinks.value.push(safeLink);
}
}
} catch (error: unknown) {
homeError.value = (error as Error)?.message || "加载首页失败";
} finally {
homeLoading.value = false;
}
};
// 加载首页推荐列表为侧边栏入口(按名称合并 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,按 pkgname 去重,spark 优先)
const loadHomeListApps = async (entryId: string) => {
if (tabApps.value[entryId]) return;
// 防止重复加载:如果正在加载中则跳过
if (loadingTabs.value.has(entryId)) return;
const urls = homeListUrls.value[entryId];
if (!urls) return;
// 标记为加载中
loadingTabs.value = new Set(loadingTabs.value).add(entryId);
const arch = window.apm_store.arch || "amd64";
const loadedApps: App[] = [];
const seenPkgnames = new Set<string>();
const parseAppList = (
rawApps: Record<string, string>[],
mode: "spark" | "apm",
): App[] =>
rawApps.map((a) => {
// 首页推荐列表的 jsonUrl 形如 /home/lists/xxx.json;服务端原始数据不含 category 字段,
// 这里提取 URL 首段目录作为分类(无真实分类时回退 "unknown"),避免污染后续优先级匹配。
const urlCategory = (urls[mode] || "")
.split("/")
.filter(Boolean)[0] || "unknown";
const category = a.Category || a.category || urlCategory;
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;
}
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,
rootAbortController.signal,
)) || [];
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);
next.delete(entryId);
loadingTabs.value = next;
logger.info(`首页列表 "${entryId}" 加载完成,共 ${loadedApps.length} 个应用`);
};
// 仅并行预加载首页 homeList 板块入口(区域2 数据来源,不依赖全量应用)
const preloadHomeListApps = (): Promise<void> => {
const tasks: Promise<void>[] = [];
for (const entry of sidebarEntries.value) {
if (entry.type === "homeList") {
tasks.push(
loadHomeListApps(entry.id).catch((e: unknown) =>
logger.warn(`预加载首页列表 ${entry.id} 失败: ${e}`),
),
);
}
}
return Promise.all(tasks).then(() => undefined);
};
// 并行预加载其余分类侧边栏入口(用户点击分类时才需要,可延后)
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}`),
),
);
}
}
return Promise.all(tasks).then(() => undefined);
};
const prevScreen = () => {
if (currentScreenIndex.value > 0) {
currentScreenIndex.value--;
}
};
const nextScreen = () => {
if (currentScreenIndex.value < screenshots.value.length - 1) {
currentScreenIndex.value++;
}
};
const handleUpdate = async () => {
await openUpdateModal();
};
const handleOpenInstallSettings = () => {
showSettingsModal.value = true;
};
const handleList = () => {
openInstalledModal();
};
const handleSubmit = async () => {
try {
const result = await window.ipcRenderer.invoke("launch-submitter");
if (!result?.success) {
logger.error(
"Failed to launch submitter: " + (result?.message || "unknown error"),
);
}
} catch (error) {
logger.error(`Failed to launch submitter: ${error}`);
}
};
const openUpdateModal = async () => {
try {
if (!effectiveStoreFilter.value) {
return;
}
await updateCenterStore.open(effectiveStoreFilter.value);
} catch (error) {
logger.error(`打开更新中心失败: ${error}`);
}
};
const hasMigrationSelection = (items: UpdateCenterItem[]): boolean => {
return items.some((item) => item.isMigration === true);
};
const handleStartSelectedUpdates = async () => {
const selectedItems = updateCenterStore.getSelectedItems();
if (selectedItems.length === 0) {
return;
}
if (hasMigrationSelection(selectedItems)) {
updateCenterStore.showMigrationConfirm.value = true;
return;
}
await updateCenterStore.startSelected();
};
const confirmMigrationStart = async () => {
// 确认即关闭对话框(UX:确认动作立即生效),再执行异步迁移启动
updateCenterStore.showMigrationConfirm.value = false;
try {
await updateCenterStore.startSelected();
} catch (error) {
logger.error(
`启动迁移失败 (startSelected): ${error instanceof Error ? error.message : String(error)}`,
);
}
};
const openInstalledModal = () => {
if (
getEffectiveStoreFilter(storeFilter.value, availableSources.value) === null
) {
return;
}
showInstalledModal.value = true;
refreshInstalledApps();
};
const closeInstalledModal = () => {
showInstalledModal.value = false;
// 关闭模态框时同步清空错误 / 警告,避免下次打开时残留过期状态
installedError.value = "";
installedWarning.value = "";
};
// 根据当前启动模式和系统可用性,确定需要查询哪些 origin 的已安装应用
const resolveInstalledOrigins = (): Array<"spark" | "apm"> => {
const origins: Array<"spark" | "apm"> = [];
if (isOriginUsable(storeFilter.value, "spark", availableSources.value)) {
origins.push("spark");
}
if (isOriginUsable(storeFilter.value, "apm", availableSources.value)) {
origins.push("apm");
}
return origins;
};
const refreshInstalledApps = async () => {
// 异步竞态防护:进入时若模态框已关闭(极小概率),直接放弃本轮请求
if (!showInstalledModal.value) return;
installedLoading.value = true;
installedError.value = "";
installedWarning.value = "";
// 代次计数器:每次进入自增;await 之后若代次已变(新一轮刷新 / 关闭重开),丢弃本轮结果
const generation = ++installedRefreshGeneration.value;
try {
const origins = resolveInstalledOrigins();
// Spark 已安装列表依赖商店目录(apps.value)来枚举包名。
// 仅当目录中存在可枚举的 Spark 包时才查询 Spark
// 否则空目录会触发"全量扫描整个系统"的陷阱导致列表被全部跳过而误报为空。
const sparkPkgnameList = apps.value
.filter((a) => a.origin === "spark")
.map((a) => a.pkgname);
const effectiveOrigins = origins.filter(
(o) => o !== "spark" || sparkPkgnameList.length > 0,
);
if (effectiveOrigins.length === 0) {
installedApps.value = [];
// 目录尚未加载完成(但来源可用)时给出过渡提示,待目录加载后会自动重查
installedError.value =
apps.value.length === 0
? "正在加载应用目录,请稍候…"
: "当前系统不可用应用管理功能";
return;
}
// 并行查询每个 origin 的已安装应用:
// 用 allSettled + 每源超时,避免单一来源(如 APM)响应慢/挂起阻塞整体;
// 超时或失败的来源在下方循环标记为 failedOrigin,不影响其它来源结果。
const results = await Promise.allSettled(
effectiveOrigins.map((origin) =>
withTimeout(
window.ipcRenderer.invoke("list-installed", {
origin,
pkgnameList: origin === "spark" ? sparkPkgnameList : undefined,
}),
LIST_INSTALLED_TIMEOUT_MS,
`${origin} list-installed`,
),
),
);
// 异步结束后的回调阶段重新校验代次与模态可见性,避免陈旧竞态写入 ref
if (generation !== installedRefreshGeneration.value) return;
if (!showInstalledModal.value) return;
const combinedApps: App[] = [];
// 同一 pkgname 可能同时以 APM 与 Spark 两种来源安装,这里聚合其来源集合
const originsByPkg = new Map<string, Set<"spark" | "apm">>();
const failedOrigins: string[] = [];
for (let i = 0; i < effectiveOrigins.length; i++) {
const origin = effectiveOrigins[i];
const settled = results[i];
// allSettled: rejected(超时/异常)或 success=false 都视为该来源失败
if (settled.status !== "fulfilled" || !settled.value?.success) {
failedOrigins.push(origin);
continue;
}
const result = settled.value;
const appList = Array.isArray(result?.apps) ? result.apps : [];
for (const rawApp of appList) {
// 运行时类型守卫,避免后端字段缺失造成下游访问 undefined 抛出
if (!isInstalledAppInfo(rawApp)) continue;
const app = rawApp;
// Find matching remote app to enrich data. We look exactly for that origin.
let appInfo = apps.value.find(
(a) => a.pkgname === app.pkgname && a.origin === origin,
);
if (origin === "spark" && !appInfo) {
// Only show Spark packages that exist in the App Store catalogue
continue;
}
if (appInfo) {
appInfo.flags = app.flags;
appInfo.arch = app.arch;
appInfo.currentStatus = "installed";
appInfo.isDependency = app.isDependency;
} else {
// 如果在当前应用列表中找不到该应用,创建一个最小的 App 对象
appInfo = {
name: app.name || app.pkgname,
pkgname: app.pkgname,
version: app.version,
category: "unknown",
tags: "",
more: "",
filename: "",
torrent_address: "",
author: "",
contributor: "",
website: "",
update: "",
size: "",
img_urls: [],
icons: app.icon || "",
origin: app.origin || (app.arch?.includes("apm") ? "apm" : "spark"),
currentStatus: "installed",
arch: app.arch,
flags: app.flags,
isDependency: app.isDependency,
};
}
// 合并同一 pkgname 在多个来源的安装记录为单条,避免列表出现重复项(相同 pkgname 键冲突)
const existingIdx = combinedApps.findIndex(
(a) => a.pkgname === appInfo.pkgname,
);
if (existingIdx === -1) {
combinedApps.push(appInfo);
}
// 记录该 pkgname 当前这一来源,供卸载时判断走 APM 还是 Spark
const originSet =
originsByPkg.get(appInfo.pkgname) ?? new Set<"spark" | "apm">();
originSet.add(origin);
originsByPkg.set(appInfo.pkgname, originSet);
}
}
// 将来源集合回写到每条已安装应用,供卸载时判断应走 APM 还是 Spark
for (const app of combinedApps) {
const set = originsByPkg.get(app.pkgname);
if (set) app.origins = Array.from(set);
}
installedApps.value = combinedApps;
// 部分来源失败使用轻量 warning(不与列表同时呈现红色致命错误条),致命/全失败仍用 error
if (failedOrigins.length > 0) {
const labels = failedOrigins
.map((o) => (o === "spark" ? "Spark" : "APM"))
.join("、");
if (combinedApps.length > 0) {
installedWarning.value = `部分来源加载失败(${labels}),已安装列表可能不完整`;
} else {
installedError.value = `读取${labels}已安装应用失败`;
}
}
} catch (error: unknown) {
if (generation !== installedRefreshGeneration.value) return;
if (!showInstalledModal.value) return;
installedApps.value = [];
installedError.value = (error as Error)?.message || "读取已安装应用失败";
} finally {
// 仅最末一代次负责清理 loading,防止陈旧代次提前关闭 loading 影响后续刷新
if (generation === installedRefreshGeneration.value) {
installedLoading.value = false;
}
}
};
// 应用目录(apps.value)可能在打开"已安装应用"模态框之后才加载完成。
// 当目录长度变化、且模态框处于打开状态时,自动重查已安装应用,避免列表一直为空。
// 注意:上一版仅在 prevLen===0→len>0 触发,会遗漏目录后续更新(例如分类切换触发目录重建)。
// 现改为 len 任意 >0 的正向变化都允许触发;密集分批推送由下方 300ms 防抖合并最后一次写入。
// 每次变化都先自增 installedRefreshGeneration:即使上一轮刷新仍在加载中,也会立即失效,
// 避免用陈旧目录数据覆盖已安装列表(清理不单纯依赖定时器,代次校验兜底)。
let refreshDebounceTimer: ReturnType<typeof setTimeout> | null = null;
watch(
() => apps.value.length,
(len) => {
if (!showInstalledModal.value || len <= 0) {
return;
}
installedRefreshGeneration.value++;
if (refreshDebounceTimer !== null) {
clearTimeout(refreshDebounceTimer);
}
refreshDebounceTimer = setTimeout(() => {
refreshDebounceTimer = null;
void refreshInstalledApps();
}, 300);
},
);
onUnmounted(() => {
if (refreshDebounceTimer !== null) {
clearTimeout(refreshDebounceTimer);
}
});
const mapInstalledAppToCatalogApp = (
app: InstalledAppInfo,
origin: "spark" | "apm",
): App | null => {
let appInfo = apps.value.find(
(catalogApp) =>
catalogApp.pkgname === app.pkgname && catalogApp.origin === origin,
);
if (origin === "spark" && !appInfo) {
return null;
}
if (appInfo) {
appInfo.flags = app.flags;
appInfo.arch = app.arch;
appInfo.currentStatus = "installed";
appInfo.isDependency = app.isDependency;
return appInfo;
}
return {
name: app.name || app.pkgname,
pkgname: app.pkgname,
version: app.version,
category: "unknown",
tags: "",
more: "",
filename: "",
torrent_address: "",
author: "",
contributor: "",
website: "",
update: "",
size: "",
img_urls: [],
icons: app.icon || "",
origin: app.origin || (app.arch?.includes("apm") ? "apm" : "spark"),
currentStatus: "installed",
arch: app.arch,
flags: app.flags,
isDependency: app.isDependency,
};
};
const refreshFavoriteInstalledApps = async (): Promise<void> => {
const origins: Array<"spark" | "apm"> = [];
if (isOriginEnabled(storeFilter.value, "spark") && sparkAvailable.value) {
origins.push("spark");
}
if (isOriginEnabled(storeFilter.value, "apm") && apmAvailable.value) {
origins.push("apm");
}
const refreshedApps: App[] = [];
await Promise.all(
origins.map(async (origin) => {
const pkgnameList =
origin === "spark"
? apps.value
.filter((app) => app.origin === "spark")
.map((app) => app.pkgname)
: undefined;
const result = await window.ipcRenderer.invoke("list-installed", {
origin,
pkgnameList,
});
if (!result?.success) return;
const appList = Array.isArray(result?.apps) ? result.apps : [];
for (const rawApp of appList) {
// 运行时类型守卫:避免后端字段缺失时下游访问 undefined
if (!isInstalledAppInfo(rawApp)) continue;
const appInfo = mapInstalledAppToCatalogApp(rawApp, origin);
if (appInfo) refreshedApps.push(appInfo);
}
}),
);
const refreshedKeys = new Set(
refreshedApps.map((app) => `${app.origin}:${app.pkgname}`),
);
installedApps.value = [
...installedApps.value.filter(
(app) =>
!origins.includes(app.origin) &&
!refreshedKeys.has(`${app.origin}:${app.pkgname}`),
),
...refreshedApps,
];
};
const requestUninstall = (app: App) => {
uninstallTargetApp.value = app;
showUninstallModal.value = true;
removeDownloadItem(app.pkgname);
};
const onDetailRemove = (app: App) => {
requestUninstall(app);
};
const onDetailInstall = async (app: App) => {
const initiatingUserId = currentUser.value?.id ?? null;
const download = await handleInstall(app);
if (
!download ||
initiatingUserId === null ||
!isLoggedIn.value ||
currentUser.value?.id !== initiatingUserId
) {
return;
}
pendingDownloadRecords.set(download.id, {
userId: initiatingUserId,
appKey: buildFavoriteAppKey(app),
pkgname: app.pkgname,
name: app.name,
category: app.category,
selectedOrigin: app.origin,
version: app.version,
packageArch: app.arch || parsePackageArch(app.filename),
});
};
const handleInstallCompleteForDownloadRecord = async (
_event: IpcRendererEvent,
result: DownloadResult,
) => {
const pendingRecord = pendingDownloadRecords.get(result.id);
if (!pendingRecord) return;
if (result.success) {
pendingDownloadRecords.delete(result.id);
}
if (
!result.success ||
!isLoggedIn.value ||
currentUser.value?.id !== pendingRecord.userId
) {
return;
}
const downloadRecord: Omit<DownloadedAppRecord, "id" | "downloadedAt"> = {
appKey: pendingRecord.appKey,
pkgname: pendingRecord.pkgname,
name: pendingRecord.name,
category: pendingRecord.category,
selectedOrigin: pendingRecord.selectedOrigin,
version: pendingRecord.version,
packageArch: pendingRecord.packageArch,
};
try {
await recordDownloadedApp(downloadRecord);
} catch (error: unknown) {
logger.warn({ err: error }, "记录下载应用失败");
}
};
const onDetailFavorite = async (app: App) => {
await openFavoriteSelector(app);
};
const selectDetailOrigin = (origin: "spark" | "apm") => {
if (currentApp.value?.isMerged) {
currentApp.value = { ...currentApp.value, viewingOrigin: origin };
}
};
const handleDetailRequestLogin = (message: string) => {
requireLogin(message);
};
const closeUninstallModal = () => {
showUninstallModal.value = false;
uninstallTargetApp.value = null;
};
const onUninstallSuccess = () => {
// 刷新已安装列表(如果在显示)
if (showInstalledModal.value) {
refreshInstalledApps();
}
// 更新当前详情页状态(如果在显示)
if (showModal.value && currentApp.value) {
checkAppInstalled(currentApp.value);
}
};
const closeApmInstallDialog = () => {
showApmInstallDialog.value = false;
};
const confirmApmInstall = async () => {
showApmInstallDialog.value = false;
closeDetail();
await nextTick();
const apmApp = apps.value.find((a) => a.pkgname === "apm");
if (apmApp) {
openDetail(apmApp);
} else {
searchQuery.value = "apm";
}
};
const installCompleteCallback = (pkgname?: string) => {
if (currentApp.value && (!pkgname || currentApp.value.pkgname === pkgname)) {
checkAppInstalled(currentApp.value);
}
};
watchDownloadsChange(installCompleteCallback);
const uninstallInstalledApp = (app: App) => {
requestUninstall(app);
};
const openAboutModal = () => {
showAboutModal.value = true;
};
const closeAboutModal = () => {
showAboutModal.value = false;
};
const closeSettingsModal = () => {
showSettingsModal.value = false;
};
const openExternalUrl = (url: string) => {
window.open(url, "_blank", "noopener,noreferrer");
};
const openReviewUserProfile = (review: AppReview): void => {
const current = currentUser.value;
const isCurrentUser =
review.isAuthor === true ||
(review.userId !== undefined &&
current?.id !== undefined &&
review.userId === current.id);
selectedReviewUserProfile.value = {
displayName: review.userDisplayName || "星火用户",
username: isCurrentUser ? current?.username : undefined,
avatarUrl:
review.userAvatarUrl || (isCurrentUser ? current?.avatarUrl : undefined),
coverUrl: isCurrentUser ? current?.coverUrl : undefined,
forumGroups: isCurrentUser ? current?.forumGroups : undefined,
};
showReviewUserProfileModal.value = true;
};
const requireLogin = (message: string): boolean => {
if (isLoggedIn.value) return true;
loginPromptMessage.value = message;
showLoginPrompt.value = true;
return false;
};
const openLoginFromPrompt = () => {
showLoginPrompt.value = false;
showLoginModal.value = true;
};
const clearFavoriteState = () => {
favoriteRequestGeneration.value += 1;
favoriteFolders.value = [];
activeFavoriteFolderId.value = null;
favoriteItems.value = [];
favoriteItemsByFolder.value = {};
showFavoriteSelector.value = false;
favoriteTargetApp.value = null;
favoriteSelectorDraftFolderIds.value = null;
favoriteLoading.value = false;
favoriteError.value = "";
};
const clearDownloadedState = () => {
downloadedRequestGeneration.value += 1;
downloadedApps.value = [];
downloadedLoading.value = false;
downloadedError.value = "";
};
const clearRestoreState = () => {
restoreRequestGeneration.value += 1;
restoreItems.value = [];
restoreLoading.value = false;
restoreError.value = "";
showRestoreModal.value = false;
};
const clearInstalledSyncState = () => {
syncRequestGeneration.value += 1;
syncLoading.value = false;
syncStatusMessage.value = "";
syncCandidateApps.value = [];
};
const nextFavoriteRequestGeneration = (): number => {
favoriteRequestGeneration.value += 1;
return favoriteRequestGeneration.value;
};
const nextDownloadedRequestGeneration = (): number => {
downloadedRequestGeneration.value += 1;
return downloadedRequestGeneration.value;
};
const isCurrentFavoriteRequest = (generation: number): boolean =>
favoriteRequestGeneration.value === generation && isLoggedIn.value;
const isCurrentDownloadedRequest = (
generation: number,
userId: number,
): boolean =>
downloadedRequestGeneration.value === generation &&
currentUser.value?.id === userId;
const isCurrentRestoreRequest = (generation: number, userId: number): boolean =>
restoreRequestGeneration.value === generation &&
currentUser.value?.id === userId;
const handleLogout = () => {
logout();
pendingDownloadRecords.clear();
clearFavoriteState();
clearDownloadedState();
clearRestoreState();
clearInstalledSyncState();
loadInstalledSyncPreference(null);
showLoginModal.value = false;
showLoginPrompt.value = false;
isSidebarOpen.value = false;
showUserManagementModal.value = false;
if (currentView.value === "favorites") {
currentView.value = "default";
activeTab.value = "home";
selectedCategory.value = "all";
}
};
const handleFlarumLogin = async (payload: FlarumLoginPayload) => {
loginLoading.value = true;
loginError.value = "";
try {
const flarumToken = await requestFlarumToken(payload);
const session = await exchangeFlarumToken({
flarumUserId: flarumToken.userId,
flarumToken: flarumToken.token,
});
setAuthSession(session);
clearInstalledSyncState();
loadInstalledSyncPreference(session.user.id);
showLoginModal.value = false;
} catch (error: unknown) {
loginError.value = (error as Error)?.message || "登录失败,请稍后重试";
} finally {
loginLoading.value = false;
}
};
const loadDownloadedHistory = async (): Promise<void> => {
if (!requireLogin("请登录后查看和管理账号信息。")) return;
const userId = currentUser.value?.id;
if (userId === undefined) return;
const generation = nextDownloadedRequestGeneration();
downloadedLoading.value = true;
downloadedError.value = "";
try {
const result = await listDownloadedApps(1, 50);
if (!isCurrentDownloadedRequest(generation, userId)) return;
downloadedApps.value = result.items;
} catch (error: unknown) {
if (!isCurrentDownloadedRequest(generation, userId)) return;
downloadedApps.value = [];
downloadedError.value = (error as Error)?.message || "读取下载历史失败";
} finally {
if (isCurrentDownloadedRequest(generation, userId)) {
downloadedLoading.value = false;
}
}
};
const refreshInstalledSyncCandidates = async (
isCurrentRequest: () => boolean,
): Promise<boolean> => {
const origins: Array<"spark" | "apm"> = [];
if (isOriginEnabled(storeFilter.value, "spark") && sparkAvailable.value) {
origins.push("spark");
}
if (isOriginEnabled(storeFilter.value, "apm") && apmAvailable.value) {
origins.push("apm");
}
const refreshedApps: App[] = [];
await Promise.all(
origins.map(async (origin) => {
const pkgnameList =
origin === "spark"
? apps.value
.filter((app) => app.origin === "spark")
.map((app) => app.pkgname)
: undefined;
const result = await window.ipcRenderer.invoke("list-installed", {
origin,
pkgnameList,
});
if (!result?.success) return;
const appList = Array.isArray(result?.apps) ? result.apps : [];
for (const rawApp of appList) {
// 运行时类型守卫:避免后端字段缺失时下游访问 undefined
if (!isInstalledAppInfo(rawApp)) continue;
const appInfo = mapInstalledAppToCatalogApp(rawApp, origin);
if (appInfo) refreshedApps.push(appInfo);
}
}),
);
if (!isCurrentRequest()) {
return false;
}
syncCandidateApps.value = mergeInstalledApps(
syncCandidateApps.value,
refreshedApps,
origins,
);
return true;
};
const syncInstalledAppsToAccount = async (): Promise<void> => {
if (!requireLogin("云端同步需要登录星火账号。")) return;
if (syncLoading.value) return;
const userId = currentUser.value?.id;
if (userId === undefined) return;
const generation = syncRequestGeneration.value + 1;
syncRequestGeneration.value = generation;
syncLoading.value = true;
syncStatusMessage.value = "";
try {
const refreshed = await refreshInstalledSyncCandidates(
() =>
syncRequestGeneration.value === generation &&
currentUser.value?.id === userId,
);
if (!refreshed) return;
const items = buildSyncItems(syncCandidateApps.value);
await uploadSyncedAppList({
clientArch: window.apm_store.arch || "amd64",
distro: systemInfo.value.distro,
items,
});
if (
syncRequestGeneration.value !== generation ||
currentUser.value?.id !== userId
) {
return;
}
downloadedError.value = "";
syncStatusMessage.value = "同步完成";
} catch (error: unknown) {
if (
syncRequestGeneration.value !== generation ||
currentUser.value?.id !== userId
) {
return;
}
downloadedError.value = (error as Error)?.message || "同步已安装应用失败";
syncStatusMessage.value = downloadedError.value;
} finally {
if (
syncRequestGeneration.value === generation &&
currentUser.value?.id === userId
) {
syncLoading.value = false;
}
}
};
const syncInstalledAppsNow = (): void => {
void syncInstalledAppsToAccount();
};
const openRestoreFromAccount = async (): Promise<void> => {
if (!requireLogin("云端同步需要登录星火账号。")) return;
const userId = currentUser.value?.id;
if (userId === undefined) return;
const generation = restoreRequestGeneration.value + 1;
restoreRequestGeneration.value = generation;
showRestoreModal.value = true;
restoreLoading.value = true;
restoreError.value = "";
restoreItems.value = [];
try {
const refreshed = await refreshInstalledSyncCandidates(() =>
isCurrentRestoreRequest(generation, userId),
);
if (!refreshed) return;
const result = await fetchSyncedAppList();
if (!isCurrentRestoreRequest(generation, userId)) return;
restoreItems.value = result?.items || [];
} catch (error: unknown) {
if (!isCurrentRestoreRequest(generation, userId)) return;
restoreError.value = (error as Error)?.message || "读取云端应用列表失败";
} finally {
if (isCurrentRestoreRequest(generation, userId)) {
restoreLoading.value = false;
}
}
};
const installCloudItems = (items: SyncedAppListItem[]): void => {
for (const item of items) {
const app = resolveCloudInstallCandidate(item, apps.value);
if (!app) continue;
void onDetailInstall(app);
}
showRestoreModal.value = false;
};
const maybePromptInstalledSync = async (): Promise<void> => {
if (
import.meta.env.MODE === "test" ||
!isLoggedIn.value ||
installedSyncPromptShown.value ||
installedSyncEnabled.value !== null
) {
if (isLoggedIn.value && installedSyncEnabled.value === true) {
await syncInstalledAppsToAccount();
}
return;
}
installedSyncPromptShown.value = true;
const enabled = window.confirm(
"是否启用已安装应用列表自动同步到星火账号?仅同步商店识别的非依赖应用。",
);
setInstalledSyncEnabled(enabled);
if (enabled) await syncInstalledAppsToAccount();
};
const openUserManagement = async () => {
if (!requireLogin("请登录后查看和管理账号信息。")) return;
showUserManagementModal.value = true;
isSidebarOpen.value = false;
showLoginPrompt.value = false;
await loadDownloadedHistory();
};
const loadFavoriteFolders = async (
generation = favoriteRequestGeneration.value,
): Promise<boolean> => {
const folders = await listFavoriteFolders();
if (!isCurrentFavoriteRequest(generation)) return false;
favoriteFolders.value = folders;
const activeFolderExists = folders.some(
(folder) => folder.id === activeFavoriteFolderId.value,
);
if (!activeFolderExists) {
activeFavoriteFolderId.value = folders[0]?.id ?? null;
}
return true;
};
const loadActiveFavoriteItems = async (
generation = favoriteRequestGeneration.value,
): Promise<boolean> => {
if (!activeFavoriteFolderId.value) {
if (!isCurrentFavoriteRequest(generation)) return false;
favoriteItems.value = [];
return true;
}
const items = await listFavoriteItems(activeFavoriteFolderId.value);
if (!isCurrentFavoriteRequest(generation)) return false;
favoriteItems.value = items;
favoriteItemsByFolder.value = {
...favoriteItemsByFolder.value,
[activeFavoriteFolderId.value]: items,
};
return true;
};
const loadAllFavoriteItems = async (
generation = favoriteRequestGeneration.value,
): Promise<boolean> => {
const folderIds = favoriteFolders.value.map((folder) => folder.id);
const entries = await Promise.all(
folderIds.map(async (folderId) => ({
folderId,
items: await listFavoriteItems(folderId),
})),
);
if (!isCurrentFavoriteRequest(generation)) return false;
favoriteItemsByFolder.value = Object.fromEntries(
entries.map(({ folderId, items }) => [folderId, items]),
);
favoriteItems.value = activeFavoriteFolderId.value
? (favoriteItemsByFolder.value[activeFavoriteFolderId.value] ?? [])
: [];
return true;
};
const loadFavoriteMetadataForDetail = async (): Promise<void> => {
const generation = favoriteRequestGeneration.value;
try {
const folders = await listFavoriteFolders();
if (!isCurrentFavoriteRequest(generation)) return;
const entries = await Promise.all(
folders.map(async (folder) => ({
folderId: folder.id,
items: await listFavoriteItems(folder.id),
})),
);
if (!isCurrentFavoriteRequest(generation)) return;
favoriteFolders.value = folders;
favoriteItemsByFolder.value = Object.fromEntries(
entries.map(({ folderId, items }) => [folderId, items]),
);
} catch (error: unknown) {
if (!isCurrentFavoriteRequest(generation)) return;
favoriteError.value = (error as Error)?.message || "读取收藏夹失败";
}
};
const refreshFavorites = async (): Promise<void> => {
const generation = nextFavoriteRequestGeneration();
favoriteLoading.value = true;
favoriteError.value = "";
try {
await Promise.all([
refreshFavoriteInstalledApps(),
loadFavoriteFolders(generation),
]);
if (!isCurrentFavoriteRequest(generation)) return;
await loadAllFavoriteItems(generation);
} catch (error: unknown) {
if (!isCurrentFavoriteRequest(generation)) return;
favoriteError.value = (error as Error)?.message || "读取收藏夹失败";
} finally {
if (isCurrentFavoriteRequest(generation)) favoriteLoading.value = false;
}
};
const openFavoriteSelector = async (app: App) => {
if (!requireLogin("收藏应用需要登录星火账号。")) return;
const generation = nextFavoriteRequestGeneration();
favoriteTargetApp.value = app;
favoriteSelectorDraftFolderIds.value = null;
favoriteError.value = "";
try {
const foldersLoaded = await loadFavoriteFolders(generation);
if (!foldersLoaded || !isCurrentFavoriteRequest(generation)) return;
const itemsLoaded = await loadAllFavoriteItems(generation);
if (!itemsLoaded || !isCurrentFavoriteRequest(generation)) return;
showFavoriteSelector.value = true;
} catch (error: unknown) {
if (!isCurrentFavoriteRequest(generation)) return;
favoriteError.value = (error as Error)?.message || "读取收藏夹失败";
}
};
const toFavoritePayload = (
app: App,
): Omit<FavoriteItem, "id" | "createdAt"> => ({
appKey: buildFavoriteAppKey(app),
pkgname: app.pkgname,
name: app.name,
category: app.category,
iconUrl: app.icons,
});
const saveCurrentFavoriteFolders = async (
folderIds: Array<number | "default">,
) => {
const generation = favoriteRequestGeneration.value;
const app = favoriteTargetApp.value;
if (!app) return;
try {
const numericFolderIds = folderIds.filter(
(folderId): folderId is number => typeof folderId === "number",
);
const includesFallbackDefault = folderIds.includes("default");
const nextFolderIds = new Set(numericFolderIds);
const existingByFolder = favoriteFolders.value
.map((folder) => ({
folderId: folder.id,
item: (favoriteItemsByFolder.value[folder.id] ?? []).find(
(favorite) =>
favorite.pkgname === app.pkgname &&
favorite.category === app.category,
),
}))
.filter(
(entry): entry is { folderId: number; item: FavoriteItem } =>
entry.item !== undefined,
);
const existingFolderIds = new Set(
existingByFolder.map((entry) => entry.folderId),
);
const payload = toFavoritePayload(app);
const addedItemPromises = numericFolderIds
.filter((folderId) => !existingFolderIds.has(folderId))
.map(async (folderId) => ({
folderId,
item: await addFavoriteItem(folderId, payload),
}));
const deletedEntries = existingByFolder.filter(
({ folderId }) => !nextFolderIds.has(folderId),
);
const [addedEntries] = await Promise.all([
Promise.all(addedItemPromises),
...(includesFallbackDefault ? [addFavoriteItem("default", payload)] : []),
...deletedEntries.map(({ folderId, item }) =>
deleteFavoriteItem(folderId, item.id),
),
]);
if (!isCurrentFavoriteRequest(generation)) return;
const nextItemsByFolder = { ...favoriteItemsByFolder.value };
deletedEntries.forEach(({ folderId, item }) => {
nextItemsByFolder[folderId] = (nextItemsByFolder[folderId] ?? []).filter(
(favorite) => favorite.id !== item.id,
);
});
addedEntries.forEach(({ folderId, item }) => {
nextItemsByFolder[folderId] = [
...(nextItemsByFolder[folderId] ?? []).filter(
(favorite) =>
favorite.pkgname !== app.pkgname ||
favorite.category !== app.category,
),
item,
];
});
favoriteItemsByFolder.value = nextItemsByFolder;
if (activeFavoriteFolderId.value) {
favoriteItems.value =
nextItemsByFolder[activeFavoriteFolderId.value] ?? [];
}
showFavoriteSelector.value = false;
favoriteTargetApp.value = null;
favoriteSelectorDraftFolderIds.value = null;
if (includesFallbackDefault) await refreshFavorites();
} catch (error: unknown) {
if (!isCurrentFavoriteRequest(generation)) return;
favoriteError.value = (error as Error)?.message || "更新收藏失败";
}
};
const createFavoriteFolderFromSelector = async (
draftFolderIds: Array<number | "default"> = currentFavoriteFolderIds.value,
): Promise<void> => {
const generation = favoriteRequestGeneration.value;
const name = window.prompt("请输入收藏夹名称");
const folderName = name?.trim();
if (!folderName) return;
const app = favoriteTargetApp.value;
if (!app) return;
favoriteLoading.value = true;
favoriteError.value = "";
try {
const folder = await createFavoriteFolder(folderName);
if (!isCurrentFavoriteRequest(generation)) return;
favoriteFolders.value = [
...favoriteFolders.value.filter((item) => item.id !== folder.id),
folder,
];
favoriteItemsByFolder.value = {
...favoriteItemsByFolder.value,
[folder.id]: favoriteItemsByFolder.value[folder.id] ?? [],
};
favoriteSelectorDraftFolderIds.value = [
...new Set([...draftFolderIds, folder.id]),
];
showFavoriteSelector.value = true;
} catch (error: unknown) {
if (!isCurrentFavoriteRequest(generation)) return;
favoriteError.value = (error as Error)?.message || "创建收藏夹失败";
} finally {
if (isCurrentFavoriteRequest(generation)) favoriteLoading.value = false;
}
};
const openFavoriteManagement = async () => {
if (!requireLogin("请登录后查看我的收藏。")) return;
currentView.value = "favorites";
activeTab.value = "favorites";
isSidebarOpen.value = false;
showLoginPrompt.value = false;
await refreshFavorites();
};
const selectFavoriteFolder = async (folderId: number) => {
const generation = nextFavoriteRequestGeneration();
activeFavoriteFolderId.value = folderId;
favoriteLoading.value = true;
favoriteError.value = "";
try {
await loadActiveFavoriteItems(generation);
} catch (error: unknown) {
if (!isCurrentFavoriteRequest(generation)) return;
favoriteError.value = (error as Error)?.message || "读取收藏应用失败";
} finally {
if (isCurrentFavoriteRequest(generation)) favoriteLoading.value = false;
}
};
const createFavoriteFolderFromPrompt = async () => {
const name = window.prompt("请输入收藏夹名称");
const folderName = name?.trim();
if (!folderName) return;
const generation = favoriteRequestGeneration.value;
favoriteLoading.value = true;
favoriteError.value = "";
try {
const folder = await createFavoriteFolder(folderName);
if (!isCurrentFavoriteRequest(generation)) return;
favoriteFolders.value = [
...favoriteFolders.value.filter((item) => item.id !== folder.id),
folder,
];
favoriteItemsByFolder.value = {
...favoriteItemsByFolder.value,
[folder.id]: favoriteItemsByFolder.value[folder.id] ?? [],
};
favoriteItems.value = [];
activeFavoriteFolderId.value = folder.id;
} catch (error: unknown) {
if (!isCurrentFavoriteRequest(generation)) return;
favoriteError.value = (error as Error)?.message || "创建收藏夹失败";
} finally {
if (isCurrentFavoriteRequest(generation)) favoriteLoading.value = false;
}
};
const removeSelectedFavorites = async (ids: number[]) => {
if (!activeFavoriteFolderId.value || ids.length === 0) return;
const generation = favoriteRequestGeneration.value;
favoriteLoading.value = true;
favoriteError.value = "";
try {
await bulkDeleteFavoriteItems(activeFavoriteFolderId.value, ids);
if (!isCurrentFavoriteRequest(generation)) return;
favoriteItemsByFolder.value = {
...favoriteItemsByFolder.value,
[activeFavoriteFolderId.value]: (
favoriteItemsByFolder.value[activeFavoriteFolderId.value] ?? []
).filter((favorite) => !ids.includes(favorite.id)),
};
await refreshFavorites();
} catch (error: unknown) {
if (!isCurrentFavoriteRequest(generation)) return;
favoriteError.value = (error as Error)?.message || "移除收藏失败";
} finally {
if (isCurrentFavoriteRequest(generation)) favoriteLoading.value = false;
}
};
const installResolvedFavorites = async (items: ResolvedFavoriteItem[]) => {
for (const item of items) {
if (item.selectedApp) {
await onDetailInstall(item.selectedApp);
}
}
};
// TODO: 目前 APM 商店不能暂停下载
const pauseDownload = (id: DownloadItem) => {
const download = downloads.value.find((d) => d.id === id.id);
if (download && download.status === "installing") {
// 'installing' matches type definition, previously 'downloading'
download.status = "paused";
download.logs.push({
time: Date.now(),
message: "下载已暂停",
});
}
};
// TODO: 同理,暂未实现
const resumeDownload = (id: DownloadItem) => {
const download = downloads.value.find((d) => d.id === id.id);
if (download && download.status === "paused") {
download.status = "installing"; // previously 'downloading'
download.logs.push({
time: Date.now(),
message: "继续下载...",
});
// simulateDownload(download); // removed or undefined?
}
};
const cancelDownload = (id: DownloadItem) => {
const index = downloads.value.findIndex((d) => d.id === id.id);
if (index !== -1) {
const download = downloads.value[index];
// 发送到主进程取消
window.ipcRenderer.send("cancel-install", download.id);
download.status = "failed";
download.logs.push({
time: Date.now(),
message: "下载已取消",
});
// 保留在队列中以便用户可以重试或查看日志
}
};
const retryDownload = (id: DownloadItem) => {
const download = downloads.value.find((d) => d.id === id.id);
if (download && download.status === "failed") {
download.status = "queued";
download.progress = 0;
download.downloadedSize = 0;
download.logs.push({
time: Date.now(),
message: "重新开始下载...",
});
handleRetry(download);
}
};
const clearCompletedDownloads = () => {
downloads.value = downloads.value.filter((d) => d.status !== "completed");
};
const showDownloadDetailModalFunc = (download: DownloadItem) => {
currentDownload.value = download;
showDownloadDetailModal.value = true;
};
const closeDownloadDetail = () => {
showDownloadDetailModal.value = false;
currentDownload.value = null;
};
const openDownloadedApp = (pkgname: string, origin?: "spark" | "apm") => {
// const encodedPkg = encodeURIComponent(download.pkgname);
// openApmStoreUrl(`apmstore://launch?pkg=${encodedPkg}`, {
// fallbackText: `打开应用: ${download.pkgname}`
// });
window.ipcRenderer
.invoke("launch-app", { pkgname, origin })
.catch((err) => logger.error("启动应用失败 (launch-app):", err));
};
const loadCategories = async () => {
try {
const arch = window.apm_store.arch || "amd64";
const modes: Array<"spark" | "apm"> =
storeFilter.value === "both" ? ["spark", "apm"] : [storeFilter.value];
const categoryData: Record<string, { zh: string; origins: string[] }> = {};
for (const mode of modes) {
const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`;
const path = `/${finalArch}/categories.json`;
try {
const response = await axiosInstance.get(path);
const data = response.data;
Object.keys(data).forEach((key) => {
if (categoryData[key]) {
if (!categoryData[key].origins.includes(mode)) {
categoryData[key].origins.push(mode);
}
} else {
categoryData[key] = {
zh: data[key].zh || data[key],
origins: [mode],
};
}
});
} catch (e) {
logger.error(`读取 ${mode} categories.json 失败: ${e}`);
}
}
categories.value = categoryData;
// 加载优先级配置(从 spark 目录)
await loadPriorityConfig(arch);
} catch (error) {
logger.error(`读取 categories 失败: ${error}`);
}
};
const loadSidebarConfig = async () => {
try {
const arch = window.apm_store.arch || "amd64";
const modes: Array<"spark" | "apm"> =
storeFilter.value === "both" ? ["spark", "apm"] : [storeFilter.value];
const entryMap = new Map<string, SidebarEntry>();
for (const mode of modes) {
const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`;
const path = `/${finalArch}/sidebar-config.json`;
try {
const response = await axiosInstance.get(path);
const data = response.data;
const entries = Array.isArray(data) ? data : data.entries || [];
for (const entry of entries) {
if (entry.id && entry.name) {
const existing = entryMap.get(entry.id);
if (existing) {
// 多仓库共有入口,合并来源
if (existing.origins && !existing.origins.includes(mode)) {
existing.origins.push(mode);
}
} else {
entryMap.set(entry.id, {
id: entry.id,
name: entry.name,
icon: entry.icon || "",
type: entry.type || "category",
value: entry.value || entry.id,
origins: [mode],
});
}
}
}
} catch (e) {
logger.warn(`读取 ${mode} sidebar-config.json 失败: ${e}`);
}
}
sidebarEntries.value = Array.from(entryMap.values());
if (sidebarEntries.value.length > 0) {
logger.info(`已加载 ${sidebarEntries.value.length} 个侧边栏配置入口`);
}
} catch (error) {
logger.warn(`读取 sidebar-config 失败: ${error}`);
}
};
const normalizeAppJson = (
appJson: AppJson,
category: string,
origin: "spark" | "apm",
): App => ({
name: appJson.Name,
pkgname: appJson.Pkgname,
version: appJson.Version,
filename: appJson.Filename,
torrent_address: appJson.Torrent_address,
author: appJson.Author,
contributor: appJson.Contributor,
website: appJson.Website,
update: appJson.Update,
size: appJson.Size,
more: appJson.More,
tags: appJson.Tags,
img_urls: (() => {
if (typeof appJson.img_urls === "string") {
try {
return JSON.parse(appJson.img_urls) as string[];
} catch {
return [];
}
}
return (appJson.img_urls as string[]) || [];
})(),
icons: appJson.icons,
category: category,
origin: origin,
currentStatus: "not-installed" as const,
});
const loadTabCategories = async () => {
const arch = window.apm_store.arch || "amd64";
const modes: Array<"spark" | "apm"> =
storeFilter.value === "both" ? ["spark", "apm"] : [storeFilter.value];
const newTabCategories: Record<string, Record<string, CategoryInfo>> = {};
// 并行加载所有侧边栏入口的子分类,减少串行等待
const categoryEntries = sidebarEntries.value.filter(
(e) => e.type === "category",
);
await Promise.all(
categoryEntries.map(async (entry) => {
const folderName = entry.value || entry.id;
const catData: Record<string, { zh: string; origins: string[] }> = {};
// 只查询该入口实际存在的来源仓库,避免对不存在目录的 404 重试
const entryModes = entry.origins?.length
? entry.origins.filter((o) => modes.includes(o))
: modes;
await Promise.all(
entryModes.map(async (mode) => {
const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`;
const path = `/${finalArch}/${folderName}/categories.json`;
try {
const response = await axiosInstance.get(path);
const data = response.data;
Object.keys(data).forEach((key) => {
if (catData[key]) {
if (!catData[key].origins.includes(mode)) {
catData[key].origins.push(mode);
}
} else {
catData[key] = {
zh: data[key].zh || data[key],
origins: [mode],
};
}
});
} catch {
// 该入口没有子分类,静默忽略
}
}),
);
if (Object.keys(catData).length > 0) {
newTabCategories[entry.id] = catData;
logger.info(
`入口 "${entry.id}" 加载到 ${Object.keys(catData).length} 个子分类`,
);
}
}),
);
tabCategories.value = newTabCategories;
};
const loadTabApps = async (entryId: string) => {
if (tabApps.value[entryId]) return;
// 防止重复加载:如果正在加载中则跳过
if (loadingTabs.value.has(entryId)) return;
const entry = sidebarEntries.value.find((e) => e.id === entryId);
if (!entry || entry.type !== "category") return;
// 标记为加载中
loadingTabs.value = new Set(loadingTabs.value).add(entryId);
const arch = window.apm_store.arch || "amd64";
const allModes: Array<"spark" | "apm"> =
storeFilter.value === "both" ? ["spark", "apm"] : [storeFilter.value];
// 只查询该入口实际存在的来源仓库,避免对不存在目录的 404 重试
const modes = entry.origins?.length
? entry.origins.filter((o) => allModes.includes(o))
: allModes;
const folderName = entry.value || entry.id;
const subCats = tabCategories.value[entryId];
// 收集所有需要发起的请求任务(mode × 子分类),然后全并发加载
const tasks: Promise<App[]>[] = [];
for (const mode of modes) {
const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`;
if (subCats && Object.keys(subCats).length > 0) {
for (const [subCat, catInfo] of Object.entries(subCats)) {
if (
catInfo.origins &&
catInfo.origins.length > 0 &&
!catInfo.origins.includes(mode)
)
continue;
const path = `/${finalArch}/${folderName}/${subCat}/applist.json`;
logger.info(`加载入口子分类: ${entryId}/${subCat} (来源: ${mode})`);
tasks.push(
fetchWithRetry<AppJson[]>(path, rootAbortController.signal)
.then((categoryApps) =>
(categoryApps || []).map((aj) =>
normalizeAppJson(aj, subCat, mode),
),
)
.catch((e: unknown) => {
logger.warn(
`加载入口子分类 ${entryId}/${subCat} (${mode}) 失败: ${e}`,
);
return [] as App[];
}),
);
}
} else {
const path = `/${finalArch}/${folderName}/applist.json`;
logger.info(`加载入口目录: ${entryId} (来源: ${mode})`);
tasks.push(
fetchWithRetry<AppJson[]>(path, rootAbortController.signal)
.then((categoryApps) =>
(categoryApps || []).map((aj) =>
normalizeAppJson(aj, folderName, mode),
),
)
.catch((e: unknown) => {
logger.warn(`加载入口目录 ${entryId} (${mode}) 失败: ${e}`);
return [] as App[];
}),
);
}
}
const results = await Promise.all(tasks);
const loadedApps = results.flat();
tabApps.value = { ...tabApps.value, [entryId]: loadedApps };
// 移除加载标记
const next = new Set(loadingTabs.value);
next.delete(entryId);
loadingTabs.value = next;
logger.info(`入口 "${entryId}" 加载完成,共 ${loadedApps.length} 个应用`);
};
const loadApps = async (onFirstBatch?: () => void) => {
try {
logger.info("开始加载应用数据(全并发带重试)...");
const categoriesList = Object.keys(categories.value || {});
let firstBatchCallDone = false;
const arch = window.apm_store.arch || "amd64";
// 并发加载所有分类,每个分类自带重试机制
await Promise.all(
categoriesList.map(async (category) => {
const catInfo = categories.value[category];
if (!catInfo) return;
const origins = (catInfo.origins ||
(catInfo.origin ? [catInfo.origin] : [])) as string[];
await Promise.all(
origins.map(async (mode) => {
try {
const finalArch =
mode === "spark" ? `${arch}-store` : `${arch}-apm`;
const path = `/${finalArch}/${category}/applist.json`;
logger.info(`加载分类: ${category} (来源: ${mode})`);
const categoryApps = await fetchWithRetry<AppJson[]>(
path,
rootAbortController.signal,
);
const normalizedApps = (categoryApps || []).map((appJson) =>
normalizeAppJson(appJson, category, mode as "spark" | "apm"),
);
// 增量式更新,让用户尽快看到部分数据
apps.value.push(...normalizedApps);
// 只要有一个分类加载成功,就可以考虑关闭整体 loading(如果是首批逻辑)
if (!firstBatchCallDone && typeof onFirstBatch === "function") {
firstBatchCallDone = true;
onFirstBatch();
}
} catch (error) {
logger.warn(
`加载分类 ${category} 来源 ${mode} 最终失败: ${error}`,
);
}
}),
);
}),
);
// 确保即使全部失败也结束 loading
if (!firstBatchCallDone && typeof onFirstBatch === "function") {
onFirstBatch();
}
} catch (error) {
logger.error(`加载应用数据流程异常: ${error}`);
}
};
const handleSearchInput = (value: string) => {
currentView.value = "default";
searchQuery.value = value;
};
const handleSpkLink = (pkgname: string) => {
currentView.value = "default";
activeTab.value = "all";
// 尝试从已加载的应用中查找
const target = apps.value.find((a) => a.pkgname === pkgname);
if (target) {
openDetail({ ...target, _fromDeepLink: true });
} else {
// 找不到时回退到搜索
searchQuery.value = pkgname;
}
};
const handleSearchFocus = () => {
currentView.value = "default";
if (activeTab.value === "home") activeTab.value = "all";
};
// 窗口尺寸变化(含无边框窗口鼠标拉边角)时,防抖通知主进程保存当前尺寸
let saveBoundsTimer: number | undefined;
const handleWindowResize = () => {
if (saveBoundsTimer) clearTimeout(saveBoundsTimer);
saveBoundsTimer = window.setTimeout(() => {
void window.ipcRenderer.invoke("save-window-bounds");
}, 400);
};
// —— 以下为可复用的事件/IPC 监听处理器(命名函数,便于 onUnmounted 统一移除)——
// 收集"等待 loading 完成后执行一次"的 watcher,便于卸载时统一停止,避免泄漏
const pendingWatchers: Array<() => void> = [];
const registerOnceWatcher = (stop: () => void): void => {
pendingWatchers.push(stop);
};
const handleHashChange = () => {
isSubmitterView.value = window.location.hash === "#submitter";
};
const handleKeydown = (e: KeyboardEvent) => {
if (showPreview.value) {
if (e.key === "Escape") closeScreenPreview();
if (e.key === "ArrowLeft") prevScreen();
if (e.key === "ArrowRight") nextScreen();
}
if (showModal.value && e.key === "Escape") {
closeDetail();
}
};
const handleDeepLinkUpdate = () => {
if (loading.value) {
const stop = watch(loading, (val) => {
if (!val) {
openUpdateModal();
stop();
const idx = pendingWatchers.indexOf(stop);
if (idx >= 0) pendingWatchers.splice(idx, 1);
}
});
registerOnceWatcher(stop);
} else {
openUpdateModal();
}
};
const handleDeepLinkInstalled = () => {
if (loading.value) {
const stop = watch(loading, (val) => {
if (!val) {
openInstalledModal();
stop();
const idx = pendingWatchers.indexOf(stop);
if (idx >= 0) pendingWatchers.splice(idx, 1);
}
});
registerOnceWatcher(stop);
} else {
openInstalledModal();
}
};
const handleTriggerApmInstallDialog = () => {
showApmInstallDialog.value = true;
};
const handleDeepLinkInstall = (_event: IpcRendererEvent, pkgname: string) => {
const tryOpen = () => {
const target = apps.value.find((a) => a.pkgname === pkgname);
if (target) {
openDetail(target);
} else {
logger.warn(`Deep link: app ${pkgname} not found`);
}
};
if (loading.value) {
const stop = watch(loading, (val) => {
if (!val) {
tryOpen();
stop();
const idx = pendingWatchers.indexOf(stop);
if (idx >= 0) pendingWatchers.splice(idx, 1);
}
});
registerOnceWatcher(stop);
} else {
tryOpen();
}
};
const handleDeepLinkSearch = (
_event: IpcRendererEvent,
data: { pkgname: string },
) => {
const tryOpen = () => {
currentView.value = "default";
activeTab.value = "all";
const target = apps.value.find((a) => a.pkgname === data.pkgname);
if (target) {
openDetail({ ...target, _fromDeepLink: true });
} else {
searchQuery.value = data.pkgname;
logger.warn(
`Deep link: app ${data.pkgname} not found, fallback to search`,
);
}
};
if (loading.value) {
const stop = watch(loading, (val) => {
if (!val) {
tryOpen();
stop();
const idx = pendingWatchers.indexOf(stop);
if (idx >= 0) pendingWatchers.splice(idx, 1);
}
});
registerOnceWatcher(stop);
} else {
tryOpen();
}
};
const handleRemoveComplete = (
_event: IpcRendererEvent,
payload: ChannelPayload,
) => {
const pkgname = currentApp.value?.pkgname;
if (payload.success && pkgname) {
removeDownloadItem(pkgname);
}
};
// 生命周期钩子
onMounted(async () => {
initTheme();
updateCenterStore.bind();
handleHashChange();
window.addEventListener("hashchange", handleHashChange);
// 窗口尺寸变化(含无边框窗口鼠标拉边角)时,防抖通知主进程保存当前尺寸
window.addEventListener("resize", handleWindowResize);
try {
systemInfo.value = await window.ipcRenderer.invoke("get-system-info");
} catch (error: unknown) {
logger.warn({ err: error }, "读取系统信息失败");
systemInfo.value = { distro: "unknown" };
}
// 从主进程获取启动参数(--no-apm / --no-spark),再加载数据
storeFilter.value = await window.ipcRenderer.invoke("get-store-filter");
if (storeFilter.value !== "apm") {
sparkAvailable.value = await window.ipcRenderer.invoke(
"check-spark-available",
);
}
// 检查 apm 是否可用
if (storeFilter.value !== "spark") {
apmAvailable.value = await window.ipcRenderer.invoke("check-apm-available");
}
await loadCategories();
await loadSidebarConfig();
await loadHomeListEntries();
await loadTabCategories();
loading.value = true;
homeLoading.value = true;
// 区域1(links) 与 区域2 板块(homeList) 并行;全量应用也并行加载
await Promise.all([
loadHome(),
preloadHomeListApps(),
loadApps(() => {
loading.value = false; // 首屏:已有部分应用即可显示,排行稍后刷新
}),
]);
// 全量应用加载完成后再刷新排行榜,确保 spark/apm 应用均已就绪
loadRanking();
// 其余分类入口预加载(用户点击分类时才需要,延后)
preloadSidebarTabApps().then(() => {
logger.info("侧边栏入口预加载完成");
});
void maybePromptInstalledSync();
logger.info("所有应用数据加载完成");
// 设置键盘导航
document.addEventListener("keydown", handleKeydown);
// Deep link Handlers
window.ipcRenderer.on("deep-link-update", handleDeepLinkUpdate);
window.ipcRenderer.on("deep-link-installed", handleDeepLinkInstalled);
window.ipcRenderer.on(
"trigger-apm-install-dialog",
handleTriggerApmInstallDialog,
);
window.ipcRenderer.on("deep-link-install", handleDeepLinkInstall);
window.ipcRenderer.on("deep-link-search", handleDeepLinkSearch);
window.ipcRenderer.on(
"install-complete",
handleInstallCompleteForDownloadRecord,
);
window.ipcRenderer.on("remove-complete", handleRemoveComplete);
window.ipcRenderer.send("renderer-ready", { status: true });
logger.info("Renderer process is ready!");
});
onUnmounted(() => {
rootAbortController.abort();
updateCenterStore.unbind();
// 停止仍挂起的"等待 loading 完成后执行一次"的 watcher,避免泄漏
for (const stop of pendingWatchers.splice(0)) stop();
// 移除 window / document 监听
window.removeEventListener("hashchange", handleHashChange);
window.removeEventListener("resize", handleWindowResize);
document.removeEventListener("keydown", handleKeydown);
// 移除所有 IPC 监听
window.ipcRenderer.off("deep-link-update", handleDeepLinkUpdate);
window.ipcRenderer.off("deep-link-installed", handleDeepLinkInstalled);
window.ipcRenderer.off(
"trigger-apm-install-dialog",
handleTriggerApmInstallDialog,
);
window.ipcRenderer.off("deep-link-install", handleDeepLinkInstall);
window.ipcRenderer.off("deep-link-search", handleDeepLinkSearch);
window.ipcRenderer.off(
"install-complete",
handleInstallCompleteForDownloadRecord,
);
window.ipcRenderer.off("remove-complete", handleRemoveComplete);
});
// 观察器
watch(
() => currentUser.value?.id ?? null,
(userId, previousUserId) => {
loadInstalledSyncPreference(userId);
if (previousUserId !== undefined && userId !== previousUserId) {
syncRequestGeneration.value += 1;
syncLoading.value = false;
syncCandidateApps.value = [];
}
},
{ immediate: true },
);
watch(themeMode, (newVal) => {
localStorage.setItem("theme", newVal);
window.ipcRenderer.send(
"set-theme-source",
newVal === "auto" ? "system" : newVal,
);
});
watch(isDarkTheme, () => {
syncThemePreference();
});
</script>