Files
spark-store/src/App.vue
T

2331 lines
69 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>
<div
class="flex min-h-screen flex-col bg-slate-50 text-slate-900 transition-colors duration-300 dark:bg-slate-950 dark:text-slate-100 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 inset-y-0 left-0 z-50 w-64 shrink-0 transform border-r border-slate-200/70 bg-white/95 px-4 py-6 backdrop-blur transition-transform duration-300 ease-in-out dark:border-slate-800/70 dark:bg-slate-900 lg:sticky lg:top-0 lg:flex lg:h-screen 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"
@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-1">
<div
class="sticky top-0 z-30 border-b border-slate-200/70 bg-slate-50/95 px-4 py-4 backdrop-blur lg:px-10 dark:border-slate-800/70 dark:bg-slate-950/95"
>
<AppHeader
:search-query="searchQuery"
:active-tab="activeTab"
:apps-count="filteredApps.length"
@update-search="handleSearchInput"
@search-focus="handleSearchFocus"
@open-install-settings="handleOpenInstallSettings"
@open-about="openAboutModal"
@toggle-sidebar="isSidebarOpen = !isSidebarOpen"
/>
</div>
<CategoryBar
v-if="activeTab !== 'home' && Object.keys(categories).length > 0"
:categories="categories"
:selected-category="selectedCategory"
:category-counts="categoryCounts"
@select-category="selectSubCategory"
/>
<div class="px-4 py-6 lg:px-10">
<UserManagementView
v-if="currentView === 'account' && currentUser"
:user="currentUser"
:downloaded-apps="downloadedApps"
:sync-enabled="installedSyncEnabled ?? false"
:loading="downloadedLoading"
:error="downloadedError"
@open-forum="openExternalUrl(FLARUM_BASE_URL)"
@edit-profile="openExternalUrl(FLARUM_SETTINGS_URL)"
@toggle-sync="setInstalledSyncEnabled"
@sync-now="syncInstalledAppsNow"
@refresh-downloads="loadDownloadedHistory"
/>
<FavoriteFolderManager
v-else-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"
/>
<template v-else-if="activeTab === 'home'">
<HomeView
:links="homeLinks"
:lists="homeLists"
:loading="homeLoading"
:error="homeError"
:store-filter="storeFilter"
@open-detail="openDetail"
/>
</template>
<template v-else>
<AppGrid
:apps="filteredApps"
:loading="loading"
:scroll-key="activeTab + '-' + selectedCategory"
:store-filter="storeFilter"
@open-detail="openDetail"
/>
</template>
</div>
</main>
<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"
@close="closeDetail"
@install="onDetailInstall"
@remove="onDetailRemove"
@favorite="onDetailFavorite"
@request-login="handleDetailRequestLogin"
@open-preview="openScreenPreview"
@open-app="openDownloadedApp"
@check-install="checkAppInstalled"
/>
<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"
:active-origin="activeInstalledOrigin"
:store-filter="storeFilter"
:spark-available="sparkAvailable"
:apm-available="apmAvailable"
:logged-in="isLoggedIn"
:syncing="syncLoading"
@close="closeInstalledModal"
@refresh="refreshInstalledApps"
@open-app="openDownloadedApp($event.pkgname, $event.origin)"
@open-detail="openDetail"
@uninstall="uninstallInstalledApp"
@switch-origin="handleSwitchOrigin"
@sync-to-account="syncInstalledAppsToAccount"
@restore-from-account="openRestoreFromAccount"
@request-login="requireLogin('云端同步需要登录星火账号')"
/>
<AppListRestoreModal
:show="showRestoreModal"
:loading="restoreLoading"
:error="restoreError"
:items="restoreItems"
:installed-keys="installedCloudKeys"
@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)"
/>
<FavoriteFolderSelector
:show="showFavoriteSelector"
:folders="favoriteFolders"
@close="showFavoriteSelector = false"
@select-folder="addCurrentFavoriteToFolder"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
import axios from "axios";
import pino from "pino";
import AppSidebar from "./components/AppSidebar.vue";
import AppHeader from "./components/AppHeader.vue";
import AppGrid from "./components/AppGrid.vue";
import HomeView from "./components/HomeView.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 UserManagementView from "./components/UserManagementView.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,
exchangeFlarumToken,
fetchSyncedAppList,
listDownloadedApps,
listFavoriteFolders,
listFavoriteItems,
recordDownloadedApp,
uploadSyncedAppList,
} from "./modules/backendApi";
import { requestFlarumToken } from "./modules/flarumAuth";
import {
currentUser,
isLoggedIn,
logout,
setAuthSession,
} from "./global/authState";
import {
getAllowedInstalledOrigin,
getEffectiveStoreFilter,
getDefaultInstalledOrigin,
isOriginEnabled,
} 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,
mergeInstalledApps,
} from "./modules/appListSync";
import type {
App,
AppJson,
DownloadItem,
DownloadResult,
ChannelPayload,
CategoryInfo,
HomeLink,
HomeList,
FlarumLoginPayload,
SidebarEntry,
UpdateCenterItem,
ReviewTags,
FavoriteFolder,
FavoriteItem,
InstalledAppInfo,
ResolvedFavoriteItem,
SystemInfo,
DownloadedAppRecord,
SyncedAppListItem,
} 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,
retries = 3,
delay = 1000,
): Promise<T> => {
try {
const response = await axiosInstance.get<T>(url);
return response.data;
} catch (error) {
if (retries > 0) {
await new Promise((resolve) => setTimeout(resolve, delay));
return fetchWithRetry(url, retries - 1, delay * 2);
}
throw error;
}
};
// 响应式状态
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 categories: Ref<Record<string, CategoryInfo>> = ref({});
const apps: Ref<App[]> = ref([]);
const activeTab = ref("home");
type MainView = "default" | "account" | "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 activeInstalledOrigin = ref<"apm" | "spark">("apm");
const installedApps = ref<App[]>([]);
const installedLoading = ref(false);
const installedError = ref("");
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 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 showFavoriteSelector = ref(false);
const favoriteTargetApp = ref<App | 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 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 filteredApps = computed(() => {
let result = [...baseApps.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(() => {
if (searchQuery.value.trim()) {
return countSearchMatchesByCategory(baseApps.value, searchQuery.value);
}
const counts: Record<string, number> = { all: apps.value.length };
apps.value.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) {
counts[entry.id] = allApps.filter(
(app) => app.category === entry.value,
).length;
} else {
counts[entry.id] = 0;
}
});
return counts;
});
const currentDisplayApp = computed(() => getDisplayApp(currentApp.value));
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 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 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 &&
homeLists.value.length === 0
) {
loadHome();
}
};
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" && entry.value) return entry.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}/${category}/${pkgname}/app.json`;
const response = await fetch(appJsonUrl);
if (!response.ok) return null;
const appJson = await response.json();
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:
typeof appJson.img_urls === "string"
? (JSON.parse(appJson.img_urls) as string[])
: appJson.img_urls || [],
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;
}
};
const openDetail = async (app: App | Record<string, unknown>) => {
// 提取 pkgname 和 category(必须存在)
const pkgname = (app as Record<string, unknown>).pkgname as string;
const category =
((app as Record<string, unknown>).category as string) || "unknown";
// 检查是否来自 HomeView 或 DeepLink(需要重新获取完整信息)
const fromHomeView = (app as Record<string, unknown>)._fromHomeView === true;
const fromDeepLink = (app as Record<string, unknown>)._fromDeepLink === true;
const needFetchFromStore = fromHomeView || fromDeepLink;
if (!pkgname) {
console.warn("openDetail: 缺少 pkgname", app);
return;
}
// 首先尝试从当前已经处理好(合并/筛选)的 filteredApps 中查找
let fullApp = filteredApps.value.find((a) => a.pkgname === pkgname);
// 如果没找到,回退到全局 apps 中查找
if (!fullApp) {
fullApp = apps.value.find((a) => a.pkgname === pkgname);
}
let finalApp: App;
// 来自 HomeView 或 DeepLink 的应用需要重新从仓库获取完整信息
if (needFetchFromStore) {
// 从 Spark 和 APM 仓库获取完整的应用信息
const [sparkApp, apmApp] = await Promise.all([
storeFilter.value !== "apm"
? fetchAppFromStore(pkgname, category, "spark")
: Promise.resolve(null),
storeFilter.value !== "spark"
? fetchAppFromStore(pkgname, category, "apm")
: Promise.resolve(null),
]);
// 构建合并的应用对象
if (sparkApp || apmApp) {
// 如果两个仓库都有这个应用,创建合并对象
if (sparkApp && apmApp) {
// 根据优先级配置决定默认显示哪个版本
const defaultOrigin = 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 = {
name: ((app as Record<string, unknown>).name as string) || "",
pkgname: pkgname,
version: ((app as Record<string, unknown>).version as string) || "",
filename: ((app as Record<string, unknown>).filename as string) || "",
category: category,
torrent_address: "",
author: "",
contributor: "",
website: "",
update: "",
size: "",
more: ((app as Record<string, unknown>).more as string) || "",
tags: "",
img_urls: [],
icons: "",
origin:
((app as Record<string, unknown>).origin as "spark" | "apm") || "apm",
currentStatus: "not-installed",
} as App;
}
} else {
// 非 HomeView 来源,使用原来的逻辑
if (fullApp) {
finalApp = fullApp;
} else {
// 构造一个最小可用的 App 对象
finalApp = {
name: ((app as Record<string, unknown>).name as string) || "",
pkgname: pkgname,
version: ((app as Record<string, unknown>).version as string) || "",
filename: ((app as Record<string, unknown>).filename as string) || "",
category: category,
torrent_address: "",
author: "",
contributor: "",
website: "",
update: "",
size: "",
more: ((app as Record<string, unknown>).more as string) || "",
tags: "",
img_urls: [],
icons: "",
origin:
((app as Record<string, unknown>).origin as "spark" | "apm") || "apm",
currentStatus: "not-installed",
} as App;
}
}
// 检查 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),
]);
if (sparkInstalled && !apmInstalled) {
finalApp.viewingOrigin = "spark";
} else if (apmInstalled && !sparkInstalled) {
finalApp.viewingOrigin = "apm";
} else {
// 若都安装或都未安装,根据优先级配置决定默认展示
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);
nextTick(() => {
const modal = document.querySelector(
'[data-app-modal="detail"] .modal-panel',
);
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 homeLists = ref<HomeList[]>([]);
const homeLoading = ref(false);
const homeError = ref("");
const loadHome = async () => {
homeLoading.value = true;
homeError.value = "";
homeLinks.value = [];
homeLists.value = [];
try {
const arch = window.apm_store.arch || "amd64";
const modes: Array<"spark" | "apm"> =
storeFilter.value === "both" ? ["spark", "apm"] : [storeFilter.value];
for (const mode of modes) {
const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`;
const base = `${APM_STORE_BASE_URL}/${finalArch}/home`;
// homelinks.json
try {
const res = await fetch(`${base}/homelinks.json`);
if (res.ok) {
const links = await res.json();
const taggedLinks = links.map((l: HomeLink) => ({
...l,
origin: mode,
}));
homeLinks.value.push(...taggedLinks);
}
} catch (e) {
console.warn(`Failed to load ${mode} homelinks.json`, e);
}
// homelist.json
try {
const res2 = await fetch(`${base}/homelist.json`);
if (res2.ok) {
const lists = await res2.json();
for (const item of lists) {
if (item.type === "appList" && item.jsonUrl) {
try {
const url = `${APM_STORE_BASE_URL}/${finalArch}${item.jsonUrl}`;
const r = await fetch(url);
if (r.ok) {
const appsJson = await r.json();
const rawApps = appsJson || [];
const apps = await Promise.all(
rawApps.map(async (a: Record<string, string>) => {
const baseApp = {
name: a.Name || a.name || a.Pkgname || a.PkgName || "",
pkgname: a.Pkgname || a.pkgname || "",
category: a.Category || a.category || "unknown",
more: a.More || a.more || "",
version: a.Version || "",
filename: a.Filename || a.filename || "",
origin: mode as "spark" | "apm",
};
try {
const realAppUrl = `${APM_STORE_BASE_URL}/${finalArch}/${baseApp.category}/${baseApp.pkgname}/app.json`;
const realRes = await fetch(realAppUrl);
if (realRes.ok) {
const realApp = await realRes.json();
if (realApp.Filename)
baseApp.filename = realApp.Filename;
if (realApp.More) baseApp.more = realApp.More;
if (realApp.Name) baseApp.name = realApp.Name;
}
} catch (e) {
console.warn(
`Failed to fetch real app.json for ${baseApp.pkgname}`,
e,
);
}
return baseApp;
}),
);
homeLists.value.push({
title: `${item.name || "推荐"} (${mode === "spark" ? "星火" : "APM"})`,
apps,
});
}
} catch (e) {
console.warn("Failed to load home list", item, e);
}
}
}
}
} catch (e) {
console.warn(`Failed to load ${mode} homelist.json`, e);
}
}
} catch (error: unknown) {
homeError.value = (error as Error)?.message || "加载首页失败";
} finally {
homeLoading.value = false;
}
};
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 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 () => {
updateCenterStore.showMigrationConfirm.value = false;
await updateCenterStore.startSelected();
};
const openInstalledModal = () => {
const defaultOrigin = getDefaultInstalledOrigin(
storeFilter.value,
availableSources.value,
);
if (!defaultOrigin) {
return;
}
showInstalledModal.value = true;
activeInstalledOrigin.value =
getAllowedInstalledOrigin(
storeFilter.value,
activeInstalledOrigin.value,
availableSources.value,
) ?? defaultOrigin;
refreshInstalledApps();
};
const closeInstalledModal = () => {
showInstalledModal.value = false;
};
const handleSwitchOrigin = (origin: "apm" | "spark") => {
activeInstalledOrigin.value =
getAllowedInstalledOrigin(
storeFilter.value,
origin,
availableSources.value,
) ?? activeInstalledOrigin.value;
refreshInstalledApps();
};
const refreshInstalledApps = async () => {
installedLoading.value = true;
installedError.value = "";
try {
const origin = getAllowedInstalledOrigin(
storeFilter.value,
activeInstalledOrigin.value,
availableSources.value,
);
if (!origin) {
installedApps.value = [];
installedError.value = "当前系统不可用应用管理功能";
return;
}
activeInstalledOrigin.value = origin;
if (!isOriginEnabled(storeFilter.value, origin)) {
installedApps.value = [];
installedError.value = `当前启动模式已禁用 ${origin === "spark" ? "Spark" : "APM"} 软件管理`;
return;
}
// Spark 优化:只检查远端商店目录中的应用,避免全量扫描
let pkgnameList: string[] | undefined;
if (origin === "spark") {
pkgnameList = apps.value
.filter((a) => a.origin === "spark")
.map((a) => a.pkgname);
}
const result = await window.ipcRenderer.invoke("list-installed", {
origin,
pkgnameList,
});
if (!result?.success) {
installedApps.value = [];
installedError.value = result?.message || "读取已安装应用失败";
return;
}
installedApps.value = [];
for (const app of result.apps) {
// 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,
};
}
installedApps.value.push(appInfo);
}
} catch (error: unknown) {
installedApps.value = [];
installedError.value = (error as Error)?.message || "读取已安装应用失败";
} finally {
installedLoading.value = false;
}
};
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;
for (const app of result.apps as InstalledAppInfo[]) {
const appInfo = mapInstalledAppToCatalogApp(app, 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 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 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 = [];
showFavoriteSelector.value = false;
favoriteTargetApp.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 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();
syncRequestGeneration.value += 1;
syncLoading.value = false;
syncCandidateApps.value = [];
loadInstalledSyncPreference(null);
showLoginModal.value = false;
showLoginPrompt.value = false;
isSidebarOpen.value = false;
if (currentView.value === "favorites" || currentView.value === "account") {
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);
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;
for (const app of result.apps as InstalledAppInfo[]) {
const appInfo = mapInstalledAppToCatalogApp(app, 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;
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 = "";
} catch (error: unknown) {
if (
syncRequestGeneration.value !== generation ||
currentUser.value?.id !== userId
) {
return;
}
downloadedError.value = (error as Error)?.message || "同步已安装应用失败";
} 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 candidates = apps.value.filter(
(candidate) =>
candidate.pkgname === item.pkgname && candidate.origin === item.origin,
);
const app =
candidates.find((candidate) => candidate.category === item.category) ??
candidates[0];
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;
currentView.value = "account";
activeTab.value = "account";
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;
return true;
};
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 loadActiveFavoriteItems(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 = favoriteRequestGeneration.value;
favoriteTargetApp.value = app;
favoriteError.value = "";
try {
const foldersLoaded = await loadFavoriteFolders(generation);
if (!foldersLoaded || !isCurrentFavoriteRequest(generation)) return;
showFavoriteSelector.value = true;
} catch (error: unknown) {
if (!isCurrentFavoriteRequest(generation)) return;
favoriteError.value = (error as Error)?.message || "读取收藏夹失败";
}
};
const addCurrentFavoriteToFolder = async (folderId: number | "default") => {
const generation = favoriteRequestGeneration.value;
const app = favoriteTargetApp.value;
if (!app) return;
try {
await addFavoriteItem(folderId, {
appKey: buildFavoriteAppKey(app),
pkgname: app.pkgname,
name: app.name,
category: app.category,
iconUrl: app.icons,
});
if (!isCurrentFavoriteRequest(generation)) return;
showFavoriteSelector.value = false;
favoriteTargetApp.value = null;
await refreshFavorites();
} catch (error: unknown) {
if (!isCurrentFavoriteRequest(generation)) return;
favoriteError.value = (error as Error)?.message || "添加收藏失败";
}
};
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;
activeFavoriteFolderId.value = folder.id;
await refreshFavorites();
} 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;
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 });
};
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) {
if (!entryMap.has(entry.id)) {
entryMap.set(entry.id, {
id: entry.id,
name: entry.name,
icon: entry.icon || "",
type: entry.type || "category",
value: entry.value || entry.id,
});
}
}
}
} 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 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);
const normalizedApps = (categoryApps || []).map((appJson) => ({
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:
typeof appJson.img_urls === "string"
? (JSON.parse(appJson.img_urls) as string[])
: appJson.img_urls,
icons: appJson.icons,
category: category,
origin: mode as "spark" | "apm",
currentStatus: "not-installed" as const,
}));
// 增量式更新,让用户尽快看到部分数据
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 handleSearchFocus = () => {
currentView.value = "default";
if (activeTab.value === "home") activeTab.value = "all";
};
// 生命周期钩子
onMounted(async () => {
initTheme();
updateCenterStore.bind();
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");
}
activeInstalledOrigin.value =
getDefaultInstalledOrigin(storeFilter.value, availableSources.value) ??
"spark";
await loadCategories();
await loadSidebarConfig();
// 分类目录加载后,并行加载主页数据和所有应用列表
// 使用非阻塞方式加载,让UI先展示出来
loading.value = true;
homeLoading.value = true;
// 启动加载任务,但不等待它们完成
Promise.all([
loadHome(),
new Promise<void>((resolve) => {
loadApps(() => {
loading.value = false;
resolve();
});
}),
]).then(() => {
// 所有数据加载完成后的回调(可选)
logger.info("所有应用数据加载完成");
void maybePromptInstalledSync();
});
// 设置键盘导航
document.addEventListener("keydown", (e) => {
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();
}
});
// Deep link Handlers
window.ipcRenderer.on("deep-link-update", () => {
if (loading.value) {
const stop = watch(loading, (val) => {
if (!val) {
openUpdateModal();
stop();
}
});
} else {
openUpdateModal();
}
});
window.ipcRenderer.on("deep-link-installed", () => {
if (loading.value) {
const stop = watch(loading, (val) => {
if (!val) {
openInstalledModal();
stop();
}
});
} else {
openInstalledModal();
}
});
window.ipcRenderer.on("trigger-apm-install-dialog", () => {
showApmInstallDialog.value = true;
});
window.ipcRenderer.on(
"deep-link-install",
(_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();
}
});
} else {
tryOpen();
}
},
);
window.ipcRenderer.on(
"deep-link-search",
(_event: IpcRendererEvent, data: { pkgname: string }) => {
// 根据包名直接打开应用详情
const tryOpen = () => {
// 先切换到"全部应用"分类
currentView.value = "default";
activeTab.value = "all";
// 使用类似 HomeView 的方式打开应用,从两个仓库获取完整信息
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();
}
});
} else {
tryOpen();
}
},
);
window.ipcRenderer.on(
"install-complete",
handleInstallCompleteForDownloadRecord,
);
window.ipcRenderer.on(
"remove-complete",
(_event: IpcRendererEvent, payload: ChannelPayload) => {
const pkgname = currentApp.value?.pkgname;
if (payload.success && pkgname) {
removeDownloadItem(pkgname);
}
},
);
window.ipcRenderer.send("renderer-ready", { status: true });
logger.info("Renderer process is ready!");
});
onUnmounted(() => {
updateCenterStore.unbind();
window.ipcRenderer.off(
"install-complete",
handleInstallCompleteForDownloadRecord,
);
});
// 观察器
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>