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 @@ + + + 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), + }; + }); +};