From fd17fc127d800acbf6852b948fed2a99ac4233c6 Mon Sep 17 00:00:00 2001 From: momen Date: Tue, 19 May 2026 12:22:52 +0800 Subject: [PATCH] fix(reviews): restore modal detail review gating --- src/App.vue | 69 +++++----- src/__tests__/unit/AppDetailModal.test.ts | 154 ++++++++++++++++++++++ src/__tests__/unit/AppDetailPage.test.ts | 27 +++- src/__tests__/unit/ReviewsPanel.test.ts | 33 ++++- src/__tests__/unit/backendApi.test.ts | 42 +++++- src/components/AppDetailModal.vue | 86 +++++++++++- src/components/AppDetailPage.vue | 15 ++- src/components/ReviewsPanel.vue | 34 ++++- src/modules/backendApi.ts | 67 +++++++--- 9 files changed, 456 insertions(+), 71 deletions(-) create mode 100644 src/__tests__/unit/AppDetailModal.test.ts diff --git a/src/App.vue b/src/App.vue index 17b544e7..84198bf1 100644 --- a/src/App.vue +++ b/src/App.vue @@ -62,30 +62,8 @@ @select-category="selectSubCategory" />
-
- -
+ + { const categories: Ref> = ref({}); const apps: Ref = ref([]); const activeTab = ref("home"); -type MainView = "default" | "account" | "favorites" | "detail"; +type MainView = "default" | "account" | "favorites"; 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([]); @@ -693,8 +691,6 @@ const fetchAppFromStore = async ( }; const openDetail = async (app: App | Record) => { - detailPreviousView.value = - currentView.value === "detail" ? "default" : currentView.value; // 提取 pkgname 和 category(必须存在) const pkgname = (app as Record).pkgname as string; const category = @@ -839,14 +835,17 @@ const openDetail = async (app: App | Record) => { currentApp.value = finalApp; currentScreenIndex.value = 0; loadScreenshots(displayAppForScreenshots); - currentView.value = "detail"; + showModal.value = true; currentAppSparkInstalled.value = false; currentAppApmInstalled.value = false; checkAppInstalled(finalApp); nextTick(() => { - window.scrollTo({ top: 0, behavior: "smooth" }); + const modal = document.querySelector( + '[data-app-modal="detail"] .modal-panel', + ); + if (modal) modal.scrollTop = 0; }); }; @@ -896,11 +895,7 @@ const loadScreenshots = (app: App) => { }; const closeDetail = () => { - currentView.value = - detailPreviousView.value === "detail" - ? "default" - : detailPreviousView.value; - detailPreviousView.value = "default"; + showModal.value = false; currentApp.value = null; }; @@ -1377,7 +1372,7 @@ const onUninstallSuccess = () => { refreshInstalledApps(); } // 更新当前详情页状态(如果在显示) - if (currentView.value === "detail" && currentApp.value) { + if (showModal.value && currentApp.value) { checkAppInstalled(currentApp.value); } }; @@ -2185,7 +2180,7 @@ onMounted(async () => { if (e.key === "ArrowLeft") prevScreen(); if (e.key === "ArrowRight") nextScreen(); } - if (currentView.value === "detail" && e.key === "Escape") { + if (showModal.value && e.key === "Escape") { closeDetail(); } }); diff --git a/src/__tests__/unit/AppDetailModal.test.ts b/src/__tests__/unit/AppDetailModal.test.ts new file mode 100644 index 00000000..a073c990 --- /dev/null +++ b/src/__tests__/unit/AppDetailModal.test.ts @@ -0,0 +1,154 @@ +import { fireEvent, render, screen } from "@testing-library/vue"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import AppDetailModal from "@/components/AppDetailModal.vue"; +import type { App, ReviewTags } from "@/global/typedefinition"; + +vi.mock("axios", () => ({ + default: { + get: vi.fn(async () => ({ status: 200, data: "42" })), + }, +})); + +vi.mock("@/components/ReviewsPanel.vue", () => ({ + default: { + name: "ReviewsPanel", + props: ["appKey", "tags", "loggedIn", "canSubmit"], + template: + '
', + }, +})); + +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", +}; + +const sparkApp: App = { + ...app, + name: "WPS Spark", + version: "2.0.0", + filename: "wps_2.0.0_amd64.deb", + origin: "spark", +}; + +const apmApp: App = { + ...app, + name: "WPS APM", + origin: "apm", +}; + +const mergedApp: App = { + ...sparkApp, + isMerged: true, + sparkApp, + apmApp, + viewingOrigin: "spark", +}; + +const sparkTags: ReviewTags = { + origin: "spark", + category: "office", + pkgname: "wps", + version: "2.0.0", + packageArch: "amd64", + clientArch: "amd64", + distro: "deepin 25", +}; + +describe("AppDetailModal", () => { + beforeEach(() => { + window.apm_store.arch = "amd64"; + }); + + it("renders detail content inside a popup-style modal overlay", () => { + const { container } = render(AppDetailModal, { + attrs: { "data-app-modal": "detail" }, + props: { + show: true, + app, + screenshots: [], + sparkInstalled: false, + apmInstalled: false, + loggedIn: false, + reviewAppKey: "apm:amd64-apm:office:wps", + reviewTags: sparkTags, + }, + }); + + const overlay = container.querySelector('[data-app-modal="detail"]'); + expect(overlay).toBeTruthy(); + expect(overlay?.className).toContain("fixed"); + expect(overlay?.querySelector(".modal-panel")).toBeTruthy(); + }); + + it("updates review identity when switching a merged app origin", async () => { + render(AppDetailModal, { + props: { + show: true, + app: mergedApp, + screenshots: [], + sparkInstalled: true, + apmInstalled: true, + loggedIn: true, + reviewAppKey: "spark:amd64-store:office:wps", + reviewTags: sparkTags, + }, + }); + + expect(screen.getByTestId("reviews-panel")).toHaveAttribute( + "data-app-key", + "spark:amd64-store:office:wps", + ); + + await fireEvent.click(screen.getByRole("button", { name: "APM" })); + + expect(screen.getByTestId("reviews-panel")).toHaveAttribute( + "data-app-key", + "apm:amd64-apm:office:wps", + ); + expect(screen.getByTestId("reviews-panel")).toHaveAttribute( + "data-origin", + "apm", + ); + expect(screen.getByTestId("reviews-panel")).toHaveAttribute( + "data-version", + "1.0.0", + ); + }); + + it("marks reviews read-only when the selected origin is not installed", () => { + render(AppDetailModal, { + props: { + show: true, + app, + screenshots: [], + sparkInstalled: false, + apmInstalled: false, + loggedIn: true, + reviewAppKey: "apm:amd64-apm:office:wps", + reviewTags: sparkTags, + }, + }); + + expect(screen.getByTestId("reviews-panel")).toHaveAttribute( + "data-can-submit", + "false", + ); + }); +}); diff --git a/src/__tests__/unit/AppDetailPage.test.ts b/src/__tests__/unit/AppDetailPage.test.ts index f52f5fa7..c693757a 100644 --- a/src/__tests__/unit/AppDetailPage.test.ts +++ b/src/__tests__/unit/AppDetailPage.test.ts @@ -7,9 +7,9 @@ import type { App, ReviewTags } from "@/global/typedefinition"; vi.mock("@/components/ReviewsPanel.vue", () => ({ default: { name: "ReviewsPanel", - props: ["appKey", "tags", "loggedIn"], + props: ["appKey", "tags", "loggedIn", "canSubmit"], template: - '
', + '
', }, })); @@ -118,8 +118,8 @@ describe("AppDetailPage", () => { props: { app: mergedApp, screenshots: [], - sparkInstalled: false, - apmInstalled: false, + sparkInstalled: true, + apmInstalled: true, loggedIn: true, reviewAppKey: "spark:amd64-store:office:wps", reviewTags: sparkTags, @@ -150,4 +150,23 @@ describe("AppDetailPage", () => { "1.0.0", ); }); + + it("marks reviews read-only when the selected origin is not installed", () => { + render(AppDetailPage, { + props: { + app, + screenshots: [], + sparkInstalled: false, + apmInstalled: false, + loggedIn: true, + reviewAppKey: "apm:amd64-apm:office:wps", + reviewTags: sparkTags, + }, + }); + + expect(screen.getByTestId("reviews-panel")).toHaveAttribute( + "data-can-submit", + "false", + ); + }); }); diff --git a/src/__tests__/unit/ReviewsPanel.test.ts b/src/__tests__/unit/ReviewsPanel.test.ts index ca7a7bf3..f967b0b1 100644 --- a/src/__tests__/unit/ReviewsPanel.test.ts +++ b/src/__tests__/unit/ReviewsPanel.test.ts @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/vue"; +import { fireEvent, render, screen } from "@testing-library/vue"; import { beforeEach, describe, expect, it, vi } from "vitest"; import ReviewsPanel from "@/components/ReviewsPanel.vue"; @@ -56,6 +56,20 @@ describe("ReviewsPanel", () => { expect(fetchReviews).not.toHaveBeenCalled(); }); + it("hides the submit form when reviews are read-only", () => { + render(ReviewsPanel, { + props: { + appKey: "apm:amd64-apm:office:wps", + tags, + loggedIn: true, + canSubmit: false, + }, + }); + + expect(screen.queryByRole("button", { name: "发表评论" })).toBeNull(); + expect(screen.getByText("安装应用后可发表评论。")).toBeTruthy(); + }); + it("ignores stale review responses after app key changes", async () => { let resolveFirstSummary!: (summary: RatingSummary) => void; let resolveFirstReviews!: (reviews: AppReview[]) => void; @@ -139,4 +153,21 @@ describe("ReviewsPanel", () => { expect(screen.queryByText("first review")).toBeNull(); expect(screen.queryByText("1.0 / 5 (1)")).toBeNull(); }); + + it("shows a friendly submit error instead of raw network errors", async () => { + vi.mocked(submitReview).mockRejectedValueOnce(new Error("Network Error")); + render(ReviewsPanel, { + props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true }, + }); + + await fireEvent.update( + screen.getByPlaceholderText("分享你的使用体验"), + "好用", + ); + await fireEvent.click(screen.getByRole("button", { name: "发表评论" })); + + expect( + await screen.findByText("无法连接星火账号服务,请稍后重试。"), + ).toBeTruthy(); + }); }); diff --git a/src/__tests__/unit/backendApi.test.ts b/src/__tests__/unit/backendApi.test.ts index 33f05106..7e85567c 100644 --- a/src/__tests__/unit/backendApi.test.ts +++ b/src/__tests__/unit/backendApi.test.ts @@ -1,7 +1,7 @@ import axios from "axios"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { exchangeFlarumToken } from "@/modules/backendApi"; +import { exchangeFlarumToken, submitReview } from "@/modules/backendApi"; const axiosMocks = vi.hoisted(() => { const post = vi.fn(); @@ -57,18 +57,48 @@ describe("backend API auth exchange", () => { }, "Spark backend auth exchange failed", ); - expect(JSON.stringify(loggerMocks.error.mock.calls)).not.toContain("forum-token"); + expect(JSON.stringify(loggerMocks.error.mock.calls)).not.toContain( + "forum-token", + ); }); it("maps backend server failures to an update-required login error", async () => { - const error = Object.assign(new Error("Request failed with status code 500"), { - isAxiosError: true, - response: { status: 500 }, - }); + const error = Object.assign( + new Error("Request failed with status code 500"), + { + isAxiosError: true, + response: { status: 500 }, + }, + ); axiosMocks.post.mockRejectedValue(error); await expect( exchangeFlarumToken({ flarumUserId: "42", flarumToken: "forum-token" }), ).rejects.toThrow("星火账号服务异常,请确认后端数据库迁移已执行后重试。"); }); + + it("maps review submission connection failures to a friendly error", async () => { + const error = Object.assign(new Error("Network Error"), { + code: "ERR_NETWORK", + isAxiosError: true, + request: {}, + }); + axiosMocks.post.mockRejectedValue(error); + + await expect( + submitReview("apm:amd64-apm:office:wps", { + rating: 5, + content: "好用", + tags: { + origin: "apm", + category: "office", + pkgname: "wps", + version: "1.0.0", + packageArch: "amd64", + clientArch: "amd64", + distro: "deepin 25", + }, + }), + ).rejects.toThrow("无法连接星火账号服务,请稍后重试。"); + }); }); diff --git a/src/components/AppDetailModal.vue b/src/components/AppDetailModal.vue index 165a99ac..61087e70 100644 --- a/src/components/AppDetailModal.vue +++ b/src/components/AppDetailModal.vue @@ -179,6 +179,14 @@
+ @@ -312,6 +320,50 @@ >

暂无应用截图

+ + +
+

+ + 应用评价 +

+

+ 登录星火账号后可查看评价并发表评论。 +

+ +
+
+

+ + 应用评价 +

+

+ 安装应用后可发表评论。 +

+
@@ -439,12 +491,14 @@