From e607e4991b496bd8645c22b88a8c23216bab5a80 Mon Sep 17 00:00:00 2001 From: momen Date: Mon, 18 May 2026 23:08:29 +0800 Subject: [PATCH] feat(detail): move app details into content view --- src/App.vue | 97 +++++-- src/__tests__/unit/AppDetailPage.test.ts | 50 ++++ src/__tests__/unit/appIdentity.test.ts | 60 +++++ src/components/AppDetailPage.vue | 310 +++++++++++++++++++++++ src/modules/appIdentity.ts | 44 ++++ 5 files changed, 533 insertions(+), 28 deletions(-) create mode 100644 src/__tests__/unit/AppDetailPage.test.ts create mode 100644 src/__tests__/unit/appIdentity.test.ts create mode 100644 src/components/AppDetailPage.vue create mode 100644 src/modules/appIdentity.ts diff --git a/src/App.vue b/src/App.vue index 8f01744f..3535dec7 100644 --- a/src/App.vue +++ b/src/App.vue @@ -63,7 +63,29 @@ />
+ +
+

@@ -106,21 +128,6 @@

- - { const categories: Ref> = ref({}); const apps: Ref = ref([]); const activeTab = ref("home"); -const currentView = ref<"default" | "account" | "favorites">("default"); +type MainView = "default" | "account" | "favorites" | "detail"; +const currentView = ref("default"); +const detailPreviousView = ref("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([]); @@ -442,6 +456,23 @@ const entryCounts = computed(() => { 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(() => { + if (!currentDisplayApp.value) return null; + return buildReviewTags(currentDisplayApp.value, { + clientArch: clientArch.value, + distro: "unknown", + }); +}); + // 方法 const syncThemePreference = () => { document.documentElement.classList.toggle("dark", isDarkTheme.value); @@ -557,7 +588,8 @@ const fetchAppFromStore = async ( }; const openDetail = async (app: App | Record) => { - currentView.value = "default"; + detailPreviousView.value = + currentView.value === "detail" ? "default" : currentView.value; // 提取 pkgname 和 category(必须存在) const pkgname = (app as Record).pkgname as string; const category = @@ -702,17 +734,14 @@ const openDetail = async (app: App | Record) => { currentApp.value = finalApp; currentScreenIndex.value = 0; loadScreenshots(displayAppForScreenshots); - showModal.value = true; + currentView.value = "detail"; currentAppSparkInstalled.value = false; currentAppApmInstalled.value = false; checkAppInstalled(finalApp); nextTick(() => { - const modal = document.querySelector( - '[data-app-modal="detail"] .modal-panel', - ); - if (modal) modal.scrollTop = 0; + window.scrollTo({ top: 0, behavior: "smooth" }); }); }; @@ -762,7 +791,11 @@ const loadScreenshots = (app: App) => { }; const closeDetail = () => { - showModal.value = false; + currentView.value = + detailPreviousView.value === "detail" + ? "default" + : detailPreviousView.value; + detailPreviousView.value = "default"; currentApp.value = null; }; @@ -1075,6 +1108,14 @@ const onDetailInstall = async (app: App) => { await handleInstall(app); }; +const onDetailFavorite = (app: App) => { + logger.info(`Favorite requested for ${app.pkgname}`); +}; + +const handleDetailRequestLogin = (message: string) => { + requireLogin(message); +}; + const closeUninstallModal = () => { showUninstallModal.value = false; uninstallTargetApp.value = null; @@ -1086,7 +1127,7 @@ const onUninstallSuccess = () => { refreshInstalledApps(); } // 更新当前详情页状态(如果在显示) - if (showModal.value && currentApp.value) { + if (currentView.value === "detail" && currentApp.value) { checkAppInstalled(currentApp.value); } }; @@ -1484,7 +1525,7 @@ onMounted(async () => { if (e.key === "ArrowLeft") prevScreen(); if (e.key === "ArrowRight") nextScreen(); } - if (showModal.value && e.key === "Escape") { + if (currentView.value === "detail" && e.key === "Escape") { closeDetail(); } }); diff --git a/src/__tests__/unit/AppDetailPage.test.ts b/src/__tests__/unit/AppDetailPage.test.ts new file mode 100644 index 00000000..f758978e --- /dev/null +++ b/src/__tests__/unit/AppDetailPage.test.ts @@ -0,0 +1,50 @@ +import { fireEvent, render, screen } from "@testing-library/vue"; +import { describe, expect, it } from "vitest"; + +import AppDetailPage from "@/components/AppDetailPage.vue"; +import type { App } from "@/global/typedefinition"; + +const app: App = { + name: "WPS", + pkgname: "wps", + version: "1.0.0", + filename: "wps_1.0.0_amd64.deb", + torrent_address: "", + author: "", + contributor: "", + website: "", + update: "", + size: "110M", + more: "Office suite", + tags: "office", + img_urls: [], + icons: "", + category: "office", + origin: "apm", + currentStatus: "not-installed", +}; + +describe("AppDetailPage", () => { + it("renders as page, emits back, and gates favorite for anonymous users", async () => { + const rendered = render(AppDetailPage, { + props: { + app, + screenshots: [], + sparkInstalled: false, + apmInstalled: false, + loggedIn: false, + reviewAppKey: "apm:amd64-apm:office:wps", + reviewTags: null, + }, + }); + + expect(screen.getByText("Office suite")).toBeTruthy(); + await fireEvent.click(screen.getByRole("button", { name: "返回" })); + await fireEvent.click(screen.getByRole("button", { name: "收藏" })); + + expect(rendered.emitted("back")).toHaveLength(1); + expect(rendered.emitted("request-login")?.[0]?.[0]).toBe( + "收藏应用需要登录星火账号。", + ); + }); +}); diff --git a/src/__tests__/unit/appIdentity.test.ts b/src/__tests__/unit/appIdentity.test.ts new file mode 100644 index 00000000..ef376d10 --- /dev/null +++ b/src/__tests__/unit/appIdentity.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { + buildFavoriteAppKey, + buildReviewAppKey, + buildReviewTags, + getDisplayApp, + parsePackageArch, +} from "@/modules/appIdentity"; +import type { App } from "@/global/typedefinition"; + +const app: 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: "apm", + currentStatus: "not-installed", +}; + +describe("appIdentity", () => { + it("builds favorite and review keys", () => { + expect(buildFavoriteAppKey(app)).toBe("app:office:wps"); + expect(buildReviewAppKey(app, "amd64")).toBe("apm:amd64-apm:office:wps"); + }); + + it("parses package arch and review tags", () => { + expect(parsePackageArch(app.filename)).toBe("amd64"); + expect( + buildReviewTags(app, { clientArch: "amd64", distro: "deepin 25" }), + ).toMatchObject({ + origin: "apm", + category: "office", + pkgname: "wps", + packageArch: "amd64", + }); + }); + + it("returns selected display app from merged apps", () => { + const merged: App = { + ...app, + isMerged: true, + viewingOrigin: "spark", + sparkApp: { ...app, origin: "spark" }, + apmApp: app, + }; + expect(getDisplayApp(merged)?.origin).toBe("spark"); + }); +}); diff --git a/src/components/AppDetailPage.vue b/src/components/AppDetailPage.vue new file mode 100644 index 00000000..52c82a2f --- /dev/null +++ b/src/components/AppDetailPage.vue @@ -0,0 +1,310 @@ + + + diff --git a/src/modules/appIdentity.ts b/src/modules/appIdentity.ts new file mode 100644 index 00000000..efea36ed --- /dev/null +++ b/src/modules/appIdentity.ts @@ -0,0 +1,44 @@ +import type { App, ReviewTags } from "@/global/typedefinition"; + +export const parsePackageArch = (filename: string): string => { + const match = filename.match(/_([^_]+)\.deb$/); + return match?.[1] || "unknown"; +}; + +export const buildStoreArch = ( + origin: "spark" | "apm", + clientArch: string, +): string => { + return `${clientArch}-${origin === "spark" ? "store" : "apm"}`; +}; + +export const buildFavoriteAppKey = (app: App): string => { + return `app:${app.category || "unknown"}:${app.pkgname}`; +}; + +export const buildReviewAppKey = (app: App, clientArch: string): string => { + return `${app.origin}:${buildStoreArch(app.origin, clientArch)}:${app.category || "unknown"}:${app.pkgname}`; +}; + +export const getDisplayApp = (app: App | null): App | null => { + if (!app) return null; + if (!app.isMerged) return app; + if (app.viewingOrigin === "spark") return app.sparkApp ?? app.apmApp ?? app; + if (app.viewingOrigin === "apm") return app.apmApp ?? app.sparkApp ?? app; + return app.sparkApp ?? app.apmApp ?? app; +}; + +export const buildReviewTags = ( + app: App, + options: { clientArch: string; distro: string }, +): ReviewTags => { + return { + origin: app.origin, + category: app.category || "unknown", + pkgname: app.pkgname, + version: app.version, + packageArch: parsePackageArch(app.filename), + clientArch: options.clientArch, + distro: options.distro, + }; +};