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 @@