mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-06-22 14:13:49 +08:00
fix(reviews): restore modal detail review gating
This commit is contained in:
+32
-37
@@ -62,30 +62,8 @@
|
|||||||
@select-category="selectSubCategory"
|
@select-category="selectSubCategory"
|
||||||
/>
|
/>
|
||||||
<div class="px-4 py-6 lg:px-10">
|
<div class="px-4 py-6 lg:px-10">
|
||||||
<section
|
|
||||||
v-if="currentView === 'detail' && currentApp"
|
|
||||||
data-app-detail-page="detail"
|
|
||||||
>
|
|
||||||
<AppDetailPage
|
|
||||||
:app="currentApp"
|
|
||||||
:screenshots="screenshots"
|
|
||||||
:spark-installed="currentAppSparkInstalled"
|
|
||||||
:apm-installed="currentAppApmInstalled"
|
|
||||||
:logged-in="isLoggedIn"
|
|
||||||
:review-app-key="currentReviewAppKey"
|
|
||||||
:review-tags="currentReviewTags"
|
|
||||||
@back="closeDetail"
|
|
||||||
@install="onDetailInstall"
|
|
||||||
@remove="onDetailRemove"
|
|
||||||
@favorite="onDetailFavorite"
|
|
||||||
@request-login="handleDetailRequestLogin"
|
|
||||||
@open-preview="openScreenPreview"
|
|
||||||
@open-app="openDownloadedApp"
|
|
||||||
@check-install="checkAppInstalled"
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
<UserManagementView
|
<UserManagementView
|
||||||
v-else-if="currentView === 'account' && currentUser"
|
v-if="currentView === 'account' && currentUser"
|
||||||
:user="currentUser"
|
:user="currentUser"
|
||||||
:downloaded-apps="downloadedApps"
|
:downloaded-apps="downloadedApps"
|
||||||
:sync-enabled="installedSyncEnabled ?? false"
|
:sync-enabled="installedSyncEnabled ?? false"
|
||||||
@@ -131,6 +109,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<AppDetailModal
|
||||||
|
data-app-modal="detail"
|
||||||
|
:show="showModal"
|
||||||
|
:app="currentApp"
|
||||||
|
:screenshots="screenshots"
|
||||||
|
:spark-installed="currentAppSparkInstalled"
|
||||||
|
:apm-installed="currentAppApmInstalled"
|
||||||
|
:logged-in="isLoggedIn"
|
||||||
|
:review-app-key="currentReviewAppKey"
|
||||||
|
:review-tags="currentReviewTags"
|
||||||
|
@close="closeDetail"
|
||||||
|
@install="onDetailInstall"
|
||||||
|
@remove="onDetailRemove"
|
||||||
|
@favorite="onDetailFavorite"
|
||||||
|
@request-login="handleDetailRequestLogin"
|
||||||
|
@open-preview="openScreenPreview"
|
||||||
|
@open-app="openDownloadedApp"
|
||||||
|
@check-install="checkAppInstalled"
|
||||||
|
/>
|
||||||
|
|
||||||
<ScreenPreview
|
<ScreenPreview
|
||||||
:show="showPreview"
|
:show="showPreview"
|
||||||
:screenshots="screenshots"
|
:screenshots="screenshots"
|
||||||
@@ -259,7 +257,7 @@ import AppHeader from "./components/AppHeader.vue";
|
|||||||
import AppGrid from "./components/AppGrid.vue";
|
import AppGrid from "./components/AppGrid.vue";
|
||||||
import HomeView from "./components/HomeView.vue";
|
import HomeView from "./components/HomeView.vue";
|
||||||
import CategoryBar from "./components/CategoryBar.vue";
|
import CategoryBar from "./components/CategoryBar.vue";
|
||||||
import AppDetailPage from "./components/AppDetailPage.vue";
|
import AppDetailModal from "./components/AppDetailModal.vue";
|
||||||
import ScreenPreview from "./components/ScreenPreview.vue";
|
import ScreenPreview from "./components/ScreenPreview.vue";
|
||||||
import DownloadQueue from "./components/DownloadQueue.vue";
|
import DownloadQueue from "./components/DownloadQueue.vue";
|
||||||
import DownloadDetail from "./components/DownloadDetail.vue";
|
import DownloadDetail from "./components/DownloadDetail.vue";
|
||||||
@@ -403,12 +401,12 @@ const isDarkTheme = computed(() => {
|
|||||||
const categories: Ref<Record<string, CategoryInfo>> = ref({});
|
const categories: Ref<Record<string, CategoryInfo>> = ref({});
|
||||||
const apps: Ref<App[]> = ref([]);
|
const apps: Ref<App[]> = ref([]);
|
||||||
const activeTab = ref("home");
|
const activeTab = ref("home");
|
||||||
type MainView = "default" | "account" | "favorites" | "detail";
|
type MainView = "default" | "account" | "favorites";
|
||||||
const currentView = ref<MainView>("default");
|
const currentView = ref<MainView>("default");
|
||||||
const detailPreviousView = ref<MainView>("default");
|
|
||||||
const selectedCategory = ref("all");
|
const selectedCategory = ref("all");
|
||||||
const searchQuery = ref("");
|
const searchQuery = ref("");
|
||||||
const isSidebarOpen = ref(false);
|
const isSidebarOpen = ref(false);
|
||||||
|
const showModal = ref(false);
|
||||||
const showPreview = ref(false);
|
const showPreview = ref(false);
|
||||||
const currentScreenIndex = ref(0);
|
const currentScreenIndex = ref(0);
|
||||||
const screenshots = ref<string[]>([]);
|
const screenshots = ref<string[]>([]);
|
||||||
@@ -693,8 +691,6 @@ const fetchAppFromStore = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openDetail = async (app: App | Record<string, unknown>) => {
|
const openDetail = async (app: App | Record<string, unknown>) => {
|
||||||
detailPreviousView.value =
|
|
||||||
currentView.value === "detail" ? "default" : currentView.value;
|
|
||||||
// 提取 pkgname 和 category(必须存在)
|
// 提取 pkgname 和 category(必须存在)
|
||||||
const pkgname = (app as Record<string, unknown>).pkgname as string;
|
const pkgname = (app as Record<string, unknown>).pkgname as string;
|
||||||
const category =
|
const category =
|
||||||
@@ -839,14 +835,17 @@ const openDetail = async (app: App | Record<string, unknown>) => {
|
|||||||
currentApp.value = finalApp;
|
currentApp.value = finalApp;
|
||||||
currentScreenIndex.value = 0;
|
currentScreenIndex.value = 0;
|
||||||
loadScreenshots(displayAppForScreenshots);
|
loadScreenshots(displayAppForScreenshots);
|
||||||
currentView.value = "detail";
|
showModal.value = true;
|
||||||
|
|
||||||
currentAppSparkInstalled.value = false;
|
currentAppSparkInstalled.value = false;
|
||||||
currentAppApmInstalled.value = false;
|
currentAppApmInstalled.value = false;
|
||||||
checkAppInstalled(finalApp);
|
checkAppInstalled(finalApp);
|
||||||
|
|
||||||
nextTick(() => {
|
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 = () => {
|
const closeDetail = () => {
|
||||||
currentView.value =
|
showModal.value = false;
|
||||||
detailPreviousView.value === "detail"
|
|
||||||
? "default"
|
|
||||||
: detailPreviousView.value;
|
|
||||||
detailPreviousView.value = "default";
|
|
||||||
currentApp.value = null;
|
currentApp.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1377,7 +1372,7 @@ const onUninstallSuccess = () => {
|
|||||||
refreshInstalledApps();
|
refreshInstalledApps();
|
||||||
}
|
}
|
||||||
// 更新当前详情页状态(如果在显示)
|
// 更新当前详情页状态(如果在显示)
|
||||||
if (currentView.value === "detail" && currentApp.value) {
|
if (showModal.value && currentApp.value) {
|
||||||
checkAppInstalled(currentApp.value);
|
checkAppInstalled(currentApp.value);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -2185,7 +2180,7 @@ onMounted(async () => {
|
|||||||
if (e.key === "ArrowLeft") prevScreen();
|
if (e.key === "ArrowLeft") prevScreen();
|
||||||
if (e.key === "ArrowRight") nextScreen();
|
if (e.key === "ArrowRight") nextScreen();
|
||||||
}
|
}
|
||||||
if (currentView.value === "detail" && e.key === "Escape") {
|
if (showModal.value && e.key === "Escape") {
|
||||||
closeDetail();
|
closeDetail();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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:
|
||||||
|
'<div data-testid="reviews-panel" :data-app-key="appKey" :data-origin="tags.origin" :data-version="tags.version" :data-can-submit="String(canSubmit)"></div>',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,9 +7,9 @@ import type { App, ReviewTags } from "@/global/typedefinition";
|
|||||||
vi.mock("@/components/ReviewsPanel.vue", () => ({
|
vi.mock("@/components/ReviewsPanel.vue", () => ({
|
||||||
default: {
|
default: {
|
||||||
name: "ReviewsPanel",
|
name: "ReviewsPanel",
|
||||||
props: ["appKey", "tags", "loggedIn"],
|
props: ["appKey", "tags", "loggedIn", "canSubmit"],
|
||||||
template:
|
template:
|
||||||
'<div data-testid="reviews-panel" :data-app-key="appKey" :data-origin="tags.origin" :data-version="tags.version"></div>',
|
'<div data-testid="reviews-panel" :data-app-key="appKey" :data-origin="tags.origin" :data-version="tags.version" :data-can-submit="String(canSubmit)"></div>',
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -118,8 +118,8 @@ describe("AppDetailPage", () => {
|
|||||||
props: {
|
props: {
|
||||||
app: mergedApp,
|
app: mergedApp,
|
||||||
screenshots: [],
|
screenshots: [],
|
||||||
sparkInstalled: false,
|
sparkInstalled: true,
|
||||||
apmInstalled: false,
|
apmInstalled: true,
|
||||||
loggedIn: true,
|
loggedIn: true,
|
||||||
reviewAppKey: "spark:amd64-store:office:wps",
|
reviewAppKey: "spark:amd64-store:office:wps",
|
||||||
reviewTags: sparkTags,
|
reviewTags: sparkTags,
|
||||||
@@ -150,4 +150,23 @@ describe("AppDetailPage", () => {
|
|||||||
"1.0.0",
|
"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",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import ReviewsPanel from "@/components/ReviewsPanel.vue";
|
import ReviewsPanel from "@/components/ReviewsPanel.vue";
|
||||||
@@ -56,6 +56,20 @@ describe("ReviewsPanel", () => {
|
|||||||
expect(fetchReviews).not.toHaveBeenCalled();
|
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 () => {
|
it("ignores stale review responses after app key changes", async () => {
|
||||||
let resolveFirstSummary!: (summary: RatingSummary) => void;
|
let resolveFirstSummary!: (summary: RatingSummary) => void;
|
||||||
let resolveFirstReviews!: (reviews: AppReview[]) => void;
|
let resolveFirstReviews!: (reviews: AppReview[]) => void;
|
||||||
@@ -139,4 +153,21 @@ describe("ReviewsPanel", () => {
|
|||||||
expect(screen.queryByText("first review")).toBeNull();
|
expect(screen.queryByText("first review")).toBeNull();
|
||||||
expect(screen.queryByText("1.0 / 5 (1)")).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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { exchangeFlarumToken } from "@/modules/backendApi";
|
import { exchangeFlarumToken, submitReview } from "@/modules/backendApi";
|
||||||
|
|
||||||
const axiosMocks = vi.hoisted(() => {
|
const axiosMocks = vi.hoisted(() => {
|
||||||
const post = vi.fn();
|
const post = vi.fn();
|
||||||
@@ -57,18 +57,48 @@ describe("backend API auth exchange", () => {
|
|||||||
},
|
},
|
||||||
"Spark backend auth exchange failed",
|
"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 () => {
|
it("maps backend server failures to an update-required login error", async () => {
|
||||||
const error = Object.assign(new Error("Request failed with status code 500"), {
|
const error = Object.assign(
|
||||||
|
new Error("Request failed with status code 500"),
|
||||||
|
{
|
||||||
isAxiosError: true,
|
isAxiosError: true,
|
||||||
response: { status: 500 },
|
response: { status: 500 },
|
||||||
});
|
},
|
||||||
|
);
|
||||||
axiosMocks.post.mockRejectedValue(error);
|
axiosMocks.post.mockRejectedValue(error);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
exchangeFlarumToken({ flarumUserId: "42", flarumToken: "forum-token" }),
|
exchangeFlarumToken({ flarumUserId: "42", flarumToken: "forum-token" }),
|
||||||
).rejects.toThrow("星火账号服务异常,请确认后端数据库迁移已执行后重试。");
|
).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("无法连接星火账号服务,请稍后重试。");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -179,6 +179,14 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex w-full items-center justify-center gap-2 rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm font-medium text-slate-600 transition hover:bg-slate-50 hover:text-slate-900 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||||
|
@click="handleFavorite"
|
||||||
|
>
|
||||||
|
<i class="fas fa-star text-xs"></i>
|
||||||
|
<span>收藏</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 其他元信息 -->
|
<!-- 其他元信息 -->
|
||||||
@@ -312,6 +320,50 @@
|
|||||||
>
|
>
|
||||||
<p class="text-sm text-slate-400">暂无应用截图</p>
|
<p class="text-sm text-slate-400">暂无应用截图</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ReviewsPanel
|
||||||
|
v-if="loggedIn && activeReviewAppKey && activeReviewTags"
|
||||||
|
:app-key="activeReviewAppKey"
|
||||||
|
:tags="activeReviewTags"
|
||||||
|
:logged-in="loggedIn"
|
||||||
|
:can-submit="isinstalled"
|
||||||
|
@request-login="$emit('request-login', $event)"
|
||||||
|
/>
|
||||||
|
<section
|
||||||
|
v-else-if="!loggedIn && reviewAppKey && reviewTags"
|
||||||
|
class="rounded-2xl border border-slate-200/60 bg-slate-50/50 p-5 dark:border-slate-800/60 dark:bg-slate-800/30"
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
class="text-base font-semibold text-slate-900 dark:text-white mb-3 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<i class="fas fa-comments text-slate-400"></i>
|
||||||
|
应用评价
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
登录星火账号后可查看评价并发表评论。
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-4 inline-flex items-center rounded-xl bg-slate-800 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
|
||||||
|
@click="emit('request-login', '登录后查看和发表评论。')"
|
||||||
|
>
|
||||||
|
登录后查看评价
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
<section
|
||||||
|
v-else-if="reviewAppKey && reviewTags"
|
||||||
|
class="rounded-2xl border border-slate-200/60 bg-slate-50/50 p-5 dark:border-slate-800/60 dark:bg-slate-800/30"
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
class="text-base font-semibold text-slate-900 dark:text-white mb-3 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<i class="fas fa-comments text-slate-400"></i>
|
||||||
|
应用评价
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
安装应用后可发表评论。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -439,12 +491,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, useAttrs, ref, watch } from "vue";
|
import { computed, useAttrs, ref, watch } from "vue";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import ReviewsPanel from "@/components/ReviewsPanel.vue";
|
||||||
import { useInstallFeedback, downloads } from "../global/downloadStatus";
|
import { useInstallFeedback, downloads } from "../global/downloadStatus";
|
||||||
import {
|
import {
|
||||||
APM_STORE_BASE_URL,
|
APM_STORE_BASE_URL,
|
||||||
getHybridDefaultOrigin,
|
getHybridDefaultOrigin,
|
||||||
} from "../global/storeConfig";
|
} from "../global/storeConfig";
|
||||||
import type { App } from "../global/typedefinition";
|
import { buildReviewAppKey, buildReviewTags } from "../modules/appIdentity";
|
||||||
|
import type { App, ReviewTags } from "../global/typedefinition";
|
||||||
|
|
||||||
const attrs = useAttrs();
|
const attrs = useAttrs();
|
||||||
|
|
||||||
@@ -454,12 +508,17 @@ const props = defineProps<{
|
|||||||
screenshots: string[];
|
screenshots: string[];
|
||||||
sparkInstalled: boolean;
|
sparkInstalled: boolean;
|
||||||
apmInstalled: boolean;
|
apmInstalled: boolean;
|
||||||
|
loggedIn: boolean;
|
||||||
|
reviewAppKey: string;
|
||||||
|
reviewTags: ReviewTags | null;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "close"): void;
|
(e: "close"): void;
|
||||||
(e: "install", app: App): void;
|
(e: "install", app: App): void;
|
||||||
(e: "remove", app: App): void;
|
(e: "remove", app: App): void;
|
||||||
|
(e: "favorite", app: App): void;
|
||||||
|
(e: "request-login", message: string): void;
|
||||||
(e: "open-preview", index: number): void;
|
(e: "open-preview", index: number): void;
|
||||||
(e: "open-app", pkgname: string, origin?: "spark" | "apm"): void;
|
(e: "open-app", pkgname: string, origin?: "spark" | "apm"): void;
|
||||||
(e: "check-install", app: App): void;
|
(e: "check-install", app: App): void;
|
||||||
@@ -576,6 +635,22 @@ const iconPath = computed(() => {
|
|||||||
return `${APM_STORE_BASE_URL}/${finalArch}/${displayApp.value.category}/${displayApp.value.pkgname}/icon.png`;
|
return `${APM_STORE_BASE_URL}/${finalArch}/${displayApp.value.category}/${displayApp.value.pkgname}/icon.png`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const activeReviewAppKey = computed(() => {
|
||||||
|
if (!displayApp.value) return "";
|
||||||
|
return buildReviewAppKey(
|
||||||
|
displayApp.value,
|
||||||
|
props.reviewTags?.clientArch ?? "amd64",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeReviewTags = computed<ReviewTags | null>(() => {
|
||||||
|
if (!displayApp.value || !props.reviewTags) return null;
|
||||||
|
return buildReviewTags(displayApp.value, {
|
||||||
|
clientArch: props.reviewTags.clientArch,
|
||||||
|
distro: props.reviewTags.distro,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const downloadCount = ref<string>("");
|
const downloadCount = ref<string>("");
|
||||||
|
|
||||||
// 监听 app 变化,获取新app的下载量
|
// 监听 app 变化,获取新app的下载量
|
||||||
@@ -620,6 +695,15 @@ const handleRemove = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFavorite = () => {
|
||||||
|
if (!displayApp.value) return;
|
||||||
|
if (!props.loggedIn) {
|
||||||
|
emit("request-login", "收藏应用需要登录星火账号。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit("favorite", displayApp.value);
|
||||||
|
};
|
||||||
|
|
||||||
const openPreview = (index: number) => {
|
const openPreview = (index: number) => {
|
||||||
emit("open-preview", index);
|
emit("open-preview", index);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -200,10 +200,11 @@
|
|||||||
:app-key="reviewAppKey"
|
:app-key="reviewAppKey"
|
||||||
:tags="reviewTags"
|
:tags="reviewTags"
|
||||||
:logged-in="loggedIn"
|
:logged-in="loggedIn"
|
||||||
|
:can-submit="isInstalled"
|
||||||
@request-login="$emit('request-login', $event)"
|
@request-login="$emit('request-login', $event)"
|
||||||
/>
|
/>
|
||||||
<section
|
<section
|
||||||
v-else-if="reviewAppKey && reviewTags"
|
v-else-if="!loggedIn && reviewAppKey && reviewTags"
|
||||||
class="rounded-2xl border border-slate-200/60 bg-slate-50/50 p-5 dark:border-slate-800/60 dark:bg-slate-800/30"
|
class="rounded-2xl border border-slate-200/60 bg-slate-50/50 p-5 dark:border-slate-800/60 dark:bg-slate-800/30"
|
||||||
>
|
>
|
||||||
<h2 class="mb-2 flex items-center gap-2 text-base font-semibold">
|
<h2 class="mb-2 flex items-center gap-2 text-base font-semibold">
|
||||||
@@ -221,6 +222,18 @@
|
|||||||
登录后查看评价
|
登录后查看评价
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
<section
|
||||||
|
v-else-if="reviewAppKey && reviewTags"
|
||||||
|
class="rounded-2xl border border-slate-200/60 bg-slate-50/50 p-5 dark:border-slate-800/60 dark:bg-slate-800/30"
|
||||||
|
>
|
||||||
|
<h2 class="mb-2 flex items-center gap-2 text-base font-semibold">
|
||||||
|
<i class="fas fa-comments text-slate-400"></i>
|
||||||
|
应用评价
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
安装应用后可发表评论。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -56,6 +56,13 @@
|
|||||||
登录后发表评论
|
登录后发表评论
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-else-if="!canSubmit"
|
||||||
|
class="mb-4 text-sm text-slate-500 dark:text-slate-400"
|
||||||
|
>
|
||||||
|
安装应用后可发表评论。
|
||||||
|
</p>
|
||||||
|
|
||||||
<form v-else class="mb-4 space-y-3" @submit.prevent="submit">
|
<form v-else class="mb-4 space-y-3" @submit.prevent="submit">
|
||||||
<label
|
<label
|
||||||
class="block text-sm font-medium text-slate-600 dark:text-slate-300"
|
class="block text-sm font-medium text-slate-600 dark:text-slate-300"
|
||||||
@@ -127,11 +134,15 @@ import type {
|
|||||||
ReviewTags,
|
ReviewTags,
|
||||||
} from "@/global/typedefinition";
|
} from "@/global/typedefinition";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
appKey: string;
|
appKey: string;
|
||||||
tags: ReviewTags;
|
tags: ReviewTags;
|
||||||
loggedIn: boolean;
|
loggedIn: boolean;
|
||||||
}>();
|
canSubmit?: boolean;
|
||||||
|
}>(),
|
||||||
|
{ canSubmit: true },
|
||||||
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
"request-login": [message: string];
|
"request-login": [message: string];
|
||||||
@@ -152,6 +163,16 @@ const ratingText = computed(() => {
|
|||||||
return `${summary.value.averageRating.toFixed(1)} / 5 (${summary.value.reviewCount})`;
|
return `${summary.value.averageRating.toFixed(1)} / 5 (${summary.value.reviewCount})`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const canSubmit = computed(() => props.canSubmit);
|
||||||
|
|
||||||
|
const toReviewErrorMessage = (caught: unknown): string => {
|
||||||
|
const message = caught instanceof Error ? caught.message : "";
|
||||||
|
if (message === "Network Error") {
|
||||||
|
return "无法连接星火账号服务,请稍后重试。";
|
||||||
|
}
|
||||||
|
return message || "发表评论失败";
|
||||||
|
};
|
||||||
|
|
||||||
const clearReviewState = () => {
|
const clearReviewState = () => {
|
||||||
loadGeneration.value += 1;
|
loadGeneration.value += 1;
|
||||||
reviews.value = [];
|
reviews.value = [];
|
||||||
@@ -189,6 +210,7 @@ const loadReviews = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
|
if (!canSubmit.value) return;
|
||||||
const appKey = props.appKey;
|
const appKey = props.appKey;
|
||||||
const tags = props.tags;
|
const tags = props.tags;
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
@@ -204,7 +226,7 @@ const submit = async () => {
|
|||||||
await loadReviews();
|
await loadReviews();
|
||||||
} catch (caught: unknown) {
|
} catch (caught: unknown) {
|
||||||
if (appKey !== props.appKey) return;
|
if (appKey !== props.appKey) return;
|
||||||
error.value = (caught as Error)?.message || "发表评论失败";
|
error.value = toReviewErrorMessage(caught);
|
||||||
} finally {
|
} finally {
|
||||||
if (appKey === props.appKey) {
|
if (appKey === props.appKey) {
|
||||||
submitting.value = false;
|
submitting.value = false;
|
||||||
|
|||||||
@@ -56,6 +56,38 @@ const normalizeBackendAuthError = (error: unknown): Error => {
|
|||||||
return new Error(`星火账号服务返回异常 (${status}),请稍后重试。`);
|
return new Error(`星火账号服务返回异常 (${status}),请稍后重试。`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeBackendMutationError = (error: unknown): Error => {
|
||||||
|
if (!axios.isAxiosError(error)) {
|
||||||
|
return error instanceof Error ? error : new Error("操作失败,请稍后重试。");
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
{
|
||||||
|
code: error.code,
|
||||||
|
message: error.message,
|
||||||
|
status: error.response?.status,
|
||||||
|
},
|
||||||
|
"Spark backend mutation failed",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!error.response) {
|
||||||
|
return new Error("无法连接星火账号服务,请稍后重试。");
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = error.response.status;
|
||||||
|
if (status === 401 || status === 403) {
|
||||||
|
return new Error("请登录星火账号后重试。");
|
||||||
|
}
|
||||||
|
if (status === 422) {
|
||||||
|
return new Error("提交内容格式不正确,请检查后重试。");
|
||||||
|
}
|
||||||
|
if (status >= 500) {
|
||||||
|
return new Error("星火账号服务异常,请稍后重试。");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Error(`星火账号服务返回异常 (${status}),请稍后重试。`);
|
||||||
|
};
|
||||||
|
|
||||||
const asApiRecord = (value: unknown): ApiRecord => {
|
const asApiRecord = (value: unknown): ApiRecord => {
|
||||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||||
return value as ApiRecord;
|
return value as ApiRecord;
|
||||||
@@ -233,7 +265,9 @@ export const submitReview = async (
|
|||||||
appKey: string,
|
appKey: string,
|
||||||
payload: { rating: number; content: string; tags: ReviewTags },
|
payload: { rating: number; content: string; tags: ReviewTags },
|
||||||
): Promise<AppReview> => {
|
): Promise<AppReview> => {
|
||||||
const response = await backend.post(
|
let response: AxiosResponse;
|
||||||
|
try {
|
||||||
|
response = await backend.post(
|
||||||
`/apps/${encodeURIComponent(appKey)}/reviews`,
|
`/apps/${encodeURIComponent(appKey)}/reviews`,
|
||||||
{
|
{
|
||||||
rating: payload.rating,
|
rating: payload.rating,
|
||||||
@@ -249,6 +283,9 @@ export const submitReview = async (
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
} catch (error) {
|
||||||
|
throw normalizeBackendMutationError(error);
|
||||||
|
}
|
||||||
return toReview(asApiRecord(response.data));
|
return toReview(asApiRecord(response.data));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user