From e116dcee6325b4ce74edf824ef8fe6ce5a522dab Mon Sep 17 00:00:00 2001
From: momen
Date: Mon, 18 May 2026 23:27:56 +0800
Subject: [PATCH] feat(favorites): add cloud favorite management
---
src/App.vue | 180 ++++++++++++++++--
.../unit/FavoriteFolderManager.test.ts | 51 +++++
.../unit/favoriteAvailability.test.ts | 73 +++++++
src/components/FavoriteFolderManager.vue | 175 +++++++++++++++++
src/components/FavoriteFolderSelector.vue | 60 ++++++
src/modules/favoriteAvailability.ts | 114 +++++++++++
6 files changed, 639 insertions(+), 14 deletions(-)
create mode 100644 src/__tests__/unit/FavoriteFolderManager.test.ts
create mode 100644 src/__tests__/unit/favoriteAvailability.test.ts
create mode 100644 src/components/FavoriteFolderManager.vue
create mode 100644 src/components/FavoriteFolderSelector.vue
create mode 100644 src/modules/favoriteAvailability.ts
diff --git a/src/App.vue b/src/App.vue
index 3535dec7..02d49c85 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -95,17 +95,18 @@
账号资料与安全设置功能即将开放。
-
-
- 我的收藏
-
-
- 收藏应用列表功能即将开放。
-
-
+ :folders="favoriteFolders"
+ :active-folder-id="activeFavoriteFolderId"
+ :items="resolvedFavoriteItems"
+ :loading="favoriteLoading"
+ :error="favoriteError"
+ @select-folder="selectFavoriteFolder"
+ @create-folder="createFavoriteFolderFromPrompt"
+ @remove-selected="removeSelectedFavorites"
+ @install-selected="installResolvedFavorites"
+ />
+
+
@@ -246,6 +254,8 @@ 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 {
APM_STORE_BASE_URL,
FLARUM_BASE_URL,
@@ -269,7 +279,14 @@ import {
rankAppsBySearch,
} from "./modules/appSearch";
import { handleInstall, handleRetry } from "./modules/processInstall";
-import { exchangeFlarumToken } from "./modules/backendApi";
+import {
+ addFavoriteItem,
+ bulkDeleteFavoriteItems,
+ createFavoriteFolder,
+ exchangeFlarumToken,
+ listFavoriteFolders,
+ listFavoriteItems,
+} from "./modules/backendApi";
import { requestFlarumToken } from "./modules/flarumAuth";
import {
currentUser,
@@ -286,9 +303,11 @@ import {
import { createUpdateCenterStore } from "./modules/updateCenter";
import {
buildReviewAppKey,
+ buildFavoriteAppKey,
buildReviewTags,
getDisplayApp,
} from "./modules/appIdentity";
+import { resolveFavoriteItems } from "./modules/favoriteAvailability";
import type {
App,
AppJson,
@@ -301,6 +320,9 @@ import type {
SidebarEntry,
UpdateCenterItem,
ReviewTags,
+ FavoriteFolder,
+ FavoriteItem,
+ ResolvedFavoriteItem,
} from "./global/typedefinition";
import type { Ref } from "vue";
import type { IpcRendererEvent } from "electron";
@@ -372,6 +394,13 @@ const loginPromptMessage = ref("请登录星火账号后继续操作。");
const sparkAvailable = ref(false);
const apmAvailable = ref(false);
const sidebarEntries: Ref = ref([]);
+const favoriteFolders = ref([]);
+const activeFavoriteFolderId = ref(null);
+const favoriteItems = ref([]);
+const showFavoriteSelector = ref(false);
+const favoriteTargetApp = ref(null);
+const favoriteLoading = ref(false);
+const favoriteError = ref("");
/** 启动参数 --no-apm => 仅 Spark;--no-spark => 仅 APM;由主进程 IPC 提供 */
const storeFilter = ref<"spark" | "apm" | "both">("both");
@@ -473,6 +502,17 @@ const currentReviewTags = computed(() => {
});
});
+const resolvedFavoriteItems = computed(() =>
+ resolveFavoriteItems(
+ favoriteItems.value,
+ apps.value,
+ installedApps.value,
+ availableSources.value,
+ storeFilter.value,
+ clientArch.value,
+ ),
+);
+
// 方法
const syncThemePreference = () => {
document.documentElement.classList.toggle("dark", isDarkTheme.value);
@@ -1108,8 +1148,8 @@ const onDetailInstall = async (app: App) => {
await handleInstall(app);
};
-const onDetailFavorite = (app: App) => {
- logger.info(`Favorite requested for ${app.pkgname}`);
+const onDetailFavorite = async (app: App) => {
+ await openFavoriteSelector(app);
};
const handleDetailRequestLogin = (message: string) => {
@@ -1215,12 +1255,124 @@ const openUserManagement = () => {
showLoginPrompt.value = false;
};
-const openFavoriteManagement = () => {
+const loadFavoriteFolders = async (): Promise => {
+ favoriteFolders.value = await listFavoriteFolders();
+ if (!activeFavoriteFolderId.value && favoriteFolders.value.length > 0) {
+ activeFavoriteFolderId.value = favoriteFolders.value[0].id;
+ }
+};
+
+const loadActiveFavoriteItems = async (): Promise => {
+ if (!activeFavoriteFolderId.value) {
+ favoriteItems.value = [];
+ return;
+ }
+ favoriteItems.value = await listFavoriteItems(activeFavoriteFolderId.value);
+};
+
+const refreshFavorites = async (): Promise => {
+ favoriteLoading.value = true;
+ favoriteError.value = "";
+ try {
+ await loadFavoriteFolders();
+ await loadActiveFavoriteItems();
+ } catch (error: unknown) {
+ favoriteError.value = (error as Error)?.message || "读取收藏夹失败";
+ } finally {
+ favoriteLoading.value = false;
+ }
+};
+
+const openFavoriteSelector = async (app: App) => {
+ if (!requireLogin("收藏应用需要登录星火账号。")) return;
+ favoriteTargetApp.value = app;
+ favoriteError.value = "";
+ try {
+ await loadFavoriteFolders();
+ showFavoriteSelector.value = true;
+ } catch (error: unknown) {
+ favoriteError.value = (error as Error)?.message || "读取收藏夹失败";
+ }
+};
+
+const addCurrentFavoriteToFolder = async (folderId: number | "default") => {
+ 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,
+ });
+ showFavoriteSelector.value = false;
+ favoriteTargetApp.value = null;
+ await refreshFavorites();
+ } catch (error: unknown) {
+ 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) => {
+ activeFavoriteFolderId.value = folderId;
+ favoriteLoading.value = true;
+ favoriteError.value = "";
+ try {
+ await loadActiveFavoriteItems();
+ } catch (error: unknown) {
+ favoriteError.value = (error as Error)?.message || "读取收藏应用失败";
+ } finally {
+ favoriteLoading.value = false;
+ }
+};
+
+const createFavoriteFolderFromPrompt = async () => {
+ const name = window.prompt("请输入收藏夹名称");
+ const folderName = name?.trim();
+ if (!folderName) return;
+ favoriteLoading.value = true;
+ favoriteError.value = "";
+ try {
+ const folder = await createFavoriteFolder(folderName);
+ activeFavoriteFolderId.value = folder.id;
+ await refreshFavorites();
+ } catch (error: unknown) {
+ favoriteError.value = (error as Error)?.message || "创建收藏夹失败";
+ } finally {
+ favoriteLoading.value = false;
+ }
+};
+
+const removeSelectedFavorites = async (ids: number[]) => {
+ if (!activeFavoriteFolderId.value || ids.length === 0) return;
+ favoriteLoading.value = true;
+ favoriteError.value = "";
+ try {
+ await bulkDeleteFavoriteItems(activeFavoriteFolderId.value, ids);
+ await refreshFavorites();
+ } catch (error: unknown) {
+ favoriteError.value = (error as Error)?.message || "移除收藏失败";
+ } finally {
+ favoriteLoading.value = false;
+ }
+};
+
+const installResolvedFavorites = async (items: ResolvedFavoriteItem[]) => {
+ for (const item of items) {
+ if (item.selectedApp) {
+ await onDetailInstall(item.selectedApp);
+ }
+ }
};
// TODO: 目前 APM 商店不能暂停下载
diff --git a/src/__tests__/unit/FavoriteFolderManager.test.ts b/src/__tests__/unit/FavoriteFolderManager.test.ts
new file mode 100644
index 00000000..2e588ece
--- /dev/null
+++ b/src/__tests__/unit/FavoriteFolderManager.test.ts
@@ -0,0 +1,51 @@
+import { fireEvent, render, screen } from "@testing-library/vue";
+import { describe, expect, it } from "vitest";
+
+import FavoriteFolderManager from "@/components/FavoriteFolderManager.vue";
+import type {
+ FavoriteFolder,
+ ResolvedFavoriteItem,
+} from "@/global/typedefinition";
+
+const folder: FavoriteFolder = {
+ id: 1,
+ name: "默认收藏夹",
+ itemCount: 1,
+ createdAt: "2026-05-18T00:00:00Z",
+ updatedAt: "2026-05-18T00:00:00Z",
+};
+
+const item: ResolvedFavoriteItem = {
+ item: {
+ id: 2,
+ appKey: "app:office:wps",
+ pkgname: "wps",
+ name: "WPS",
+ category: "office",
+ iconUrl: "",
+ createdAt: "2026-05-18T00:00:00Z",
+ },
+ status: "downlisted",
+ reason: "已下架",
+ selectedApp: null,
+};
+
+describe("FavoriteFolderManager", () => {
+ it("shows downlisted favorites and emits bulk delete", async () => {
+ const rendered = render(FavoriteFolderManager, {
+ props: {
+ folders: [folder],
+ activeFolderId: 1,
+ items: [item],
+ loading: false,
+ error: "",
+ },
+ });
+
+ expect(screen.getByText("已下架")).toBeTruthy();
+ await fireEvent.click(screen.getByLabelText("选择 WPS"));
+ await fireEvent.click(screen.getByRole("button", { name: "移除选中" }));
+
+ expect(rendered.emitted("remove-selected")?.[0]?.[0]).toEqual([2]);
+ });
+});
diff --git a/src/__tests__/unit/favoriteAvailability.test.ts b/src/__tests__/unit/favoriteAvailability.test.ts
new file mode 100644
index 00000000..4b7e5c6b
--- /dev/null
+++ b/src/__tests__/unit/favoriteAvailability.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it } from "vitest";
+
+import { resolveFavoriteItems } from "@/modules/favoriteAvailability";
+import type { App, FavoriteItem } from "@/global/typedefinition";
+
+const app = (origin: "spark" | "apm", overrides: Partial = {}): App => ({
+ name: "WPS",
+ pkgname: "wps",
+ version: "1.0.0",
+ filename: "wps_1.0.0_amd64.deb",
+ torrent_address: "",
+ author: "",
+ contributor: "",
+ website: "",
+ update: "",
+ size: "",
+ more: "",
+ tags: "",
+ img_urls: [],
+ icons: "",
+ category: "office",
+ origin,
+ currentStatus: "not-installed",
+ arch: "amd64",
+ ...overrides,
+});
+
+const favorite: FavoriteItem = {
+ id: 1,
+ appKey: "app:office:wps",
+ pkgname: "wps",
+ name: "WPS",
+ category: "office",
+ iconUrl: "",
+ createdAt: "2026-05-18T00:00:00Z",
+};
+
+describe("favoriteAvailability", () => {
+ it("marks downlisted favorites", () => {
+ expect(
+ resolveFavoriteItems(
+ [favorite],
+ [],
+ [],
+ { spark: true, apm: true },
+ "both",
+ )[0].status,
+ ).toBe("downlisted");
+ });
+
+ it("selects preferred installable variant", () => {
+ const resolved = resolveFavoriteItems(
+ [favorite],
+ [app("spark"), app("apm")],
+ [],
+ { spark: true, apm: true },
+ "both",
+ )[0];
+ expect(resolved.status).toBe("installable");
+ expect(resolved.selectedApp?.origin).toBe("apm");
+ });
+
+ it("marks installed favorites", () => {
+ const resolved = resolveFavoriteItems(
+ [favorite],
+ [app("apm")],
+ [app("apm", { currentStatus: "installed" })],
+ { spark: true, apm: true },
+ "both",
+ )[0];
+ expect(resolved.status).toBe("installed");
+ });
+});
diff --git a/src/components/FavoriteFolderManager.vue b/src/components/FavoriteFolderManager.vue
new file mode 100644
index 00000000..c39b6fc1
--- /dev/null
+++ b/src/components/FavoriteFolderManager.vue
@@ -0,0 +1,175 @@
+
+
+
+
+
+ 我的收藏
+
+
+ 管理收藏夹中的应用,已下架或不可用项目会保留显示。
+
+
+
+
+
+
+
+
+
+ 加载中...
+ {{ error }}
+
+
+ 当前收藏夹暂无应用。
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/FavoriteFolderSelector.vue b/src/components/FavoriteFolderSelector.vue
new file mode 100644
index 00000000..7660b866
--- /dev/null
+++ b/src/components/FavoriteFolderSelector.vue
@@ -0,0 +1,60 @@
+
+
+
+
+
+ 添加到收藏夹
+
+
+ 选择要保存当前应用的收藏夹。
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/favoriteAvailability.ts b/src/modules/favoriteAvailability.ts
new file mode 100644
index 00000000..0da82e81
--- /dev/null
+++ b/src/modules/favoriteAvailability.ts
@@ -0,0 +1,114 @@
+import {
+ HYBRID_DEFAULT_PRIORITY,
+ getHybridDefaultOrigin,
+} from "@/global/storeConfig";
+import type {
+ App,
+ FavoriteItem,
+ ResolvedFavoriteItem,
+ StoreFilter,
+} from "@/global/typedefinition";
+
+type SourceAvailability = {
+ spark: boolean;
+ apm: boolean;
+};
+
+const normalizeArch = (arch: string): string =>
+ arch.replace(/-(store|apm)$/, "");
+
+const appMatchesFavorite = (app: App, item: FavoriteItem): boolean =>
+ app.pkgname === item.pkgname && app.category === item.category;
+
+const appMatchesClientArch = (app: App, clientArch: string): boolean => {
+ if (!app.arch) return true;
+ return normalizeArch(app.arch) === normalizeArch(clientArch);
+};
+
+const sourceAllowed = (
+ origin: "spark" | "apm",
+ available: SourceAvailability,
+ storeFilter: StoreFilter,
+): boolean => {
+ if (!available[origin]) return false;
+ if (storeFilter === "both") return true;
+ return storeFilter === origin;
+};
+
+const choosePreferredApp = (apps: App[]): App => {
+ if (apps.length === 1) return apps[0];
+
+ const referenceApp = apps.find((app) => app.origin === "spark") ?? apps[0];
+ const preferredOrigin =
+ getHybridDefaultOrigin(referenceApp) === "spark"
+ ? HYBRID_DEFAULT_PRIORITY
+ : getHybridDefaultOrigin(referenceApp);
+ return apps.find((app) => app.origin === preferredOrigin) ?? apps[0];
+};
+
+export const resolveFavoriteItems = (
+ items: FavoriteItem[],
+ catalogApps: App[],
+ installedApps: App[],
+ available: SourceAvailability,
+ storeFilter: StoreFilter,
+ clientArch = window.apm_store.arch || "amd64",
+): ResolvedFavoriteItem[] => {
+ return items.map((item) => {
+ const catalogMatches = catalogApps.filter((app) =>
+ appMatchesFavorite(app, item),
+ );
+
+ if (catalogMatches.length === 0) {
+ return {
+ item,
+ status: "downlisted",
+ reason: "已下架",
+ selectedApp: null,
+ };
+ }
+
+ const installedMatch = installedApps.find((app) =>
+ appMatchesFavorite(app, item),
+ );
+ if (installedMatch) {
+ return {
+ item,
+ status: "installed",
+ reason: "已安装",
+ selectedApp: installedMatch,
+ };
+ }
+
+ const archMatches = catalogMatches.filter((app) =>
+ appMatchesClientArch(app, clientArch),
+ );
+ if (archMatches.length === 0) {
+ return {
+ item,
+ status: "arch-unavailable",
+ reason: "当前架构不可用",
+ selectedApp: null,
+ };
+ }
+
+ const sourceMatches = archMatches.filter((app) =>
+ sourceAllowed(app.origin, available, storeFilter),
+ );
+ if (sourceMatches.length === 0) {
+ return {
+ item,
+ status: "platform-unavailable",
+ reason: "当前来源不可用",
+ selectedApp: null,
+ };
+ }
+
+ return {
+ item,
+ status: "installable",
+ reason: "可安装",
+ selectedApp: choosePreferredApp(sourceMatches),
+ };
+ });
+};