mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-06-22 06:03:49 +08:00
fix(account): record downloads after success
This commit is contained in:
+35
-9
@@ -314,6 +314,7 @@ import type {
|
|||||||
App,
|
App,
|
||||||
AppJson,
|
AppJson,
|
||||||
DownloadItem,
|
DownloadItem,
|
||||||
|
DownloadResult,
|
||||||
ChannelPayload,
|
ChannelPayload,
|
||||||
CategoryInfo,
|
CategoryInfo,
|
||||||
HomeLink,
|
HomeLink,
|
||||||
@@ -327,6 +328,7 @@ import type {
|
|||||||
InstalledAppInfo,
|
InstalledAppInfo,
|
||||||
ResolvedFavoriteItem,
|
ResolvedFavoriteItem,
|
||||||
SystemInfo,
|
SystemInfo,
|
||||||
|
DownloadedAppRecord,
|
||||||
} from "./global/typedefinition";
|
} from "./global/typedefinition";
|
||||||
import type { Ref } from "vue";
|
import type { Ref } from "vue";
|
||||||
import type { IpcRendererEvent } from "electron";
|
import type { IpcRendererEvent } from "electron";
|
||||||
@@ -407,6 +409,8 @@ const favoriteLoading = ref(false);
|
|||||||
const favoriteError = ref("");
|
const favoriteError = ref("");
|
||||||
const favoriteRequestGeneration = ref(0);
|
const favoriteRequestGeneration = ref(0);
|
||||||
const systemInfo = ref<SystemInfo>({ distro: "unknown" });
|
const systemInfo = ref<SystemInfo>({ distro: "unknown" });
|
||||||
|
type PendingDownloadRecord = Omit<DownloadedAppRecord, "id" | "downloadedAt">;
|
||||||
|
const pendingDownloadRecords = new Map<number, PendingDownloadRecord>();
|
||||||
|
|
||||||
/** 启动参数 --no-apm => 仅 Spark;--no-spark => 仅 APM;由主进程 IPC 提供 */
|
/** 启动参数 --no-apm => 仅 Spark;--no-spark => 仅 APM;由主进程 IPC 提供 */
|
||||||
const storeFilter = ref<"spark" | "apm" | "both">("both");
|
const storeFilter = ref<"spark" | "apm" | "both">("both");
|
||||||
@@ -1243,16 +1247,29 @@ const onDetailInstall = async (app: App) => {
|
|||||||
const download = await handleInstall(app);
|
const download = await handleInstall(app);
|
||||||
if (!download || !isLoggedIn.value) return;
|
if (!download || !isLoggedIn.value) return;
|
||||||
|
|
||||||
|
pendingDownloadRecords.set(download.id, {
|
||||||
|
appKey: buildFavoriteAppKey(app),
|
||||||
|
pkgname: app.pkgname,
|
||||||
|
name: app.name,
|
||||||
|
category: app.category,
|
||||||
|
selectedOrigin: app.origin,
|
||||||
|
version: app.version,
|
||||||
|
packageArch: app.arch || parsePackageArch(app.filename),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInstallCompleteForDownloadRecord = async (
|
||||||
|
_event: IpcRendererEvent,
|
||||||
|
result: DownloadResult,
|
||||||
|
) => {
|
||||||
|
const pendingRecord = pendingDownloadRecords.get(result.id);
|
||||||
|
if (!pendingRecord) return;
|
||||||
|
|
||||||
|
pendingDownloadRecords.delete(result.id);
|
||||||
|
if (!result.success || !isLoggedIn.value) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await recordDownloadedApp({
|
await recordDownloadedApp(pendingRecord);
|
||||||
appKey: buildFavoriteAppKey(app),
|
|
||||||
pkgname: app.pkgname,
|
|
||||||
name: app.name,
|
|
||||||
category: app.category,
|
|
||||||
selectedOrigin: app.origin,
|
|
||||||
version: app.version,
|
|
||||||
packageArch: app.arch || parsePackageArch(app.filename),
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logger.warn({ err: error }, "记录下载应用失败");
|
logger.warn({ err: error }, "记录下载应用失败");
|
||||||
}
|
}
|
||||||
@@ -1952,6 +1969,11 @@ onMounted(async () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
window.ipcRenderer.on(
|
||||||
|
"install-complete",
|
||||||
|
handleInstallCompleteForDownloadRecord,
|
||||||
|
);
|
||||||
|
|
||||||
window.ipcRenderer.on(
|
window.ipcRenderer.on(
|
||||||
"remove-complete",
|
"remove-complete",
|
||||||
(_event: IpcRendererEvent, payload: ChannelPayload) => {
|
(_event: IpcRendererEvent, payload: ChannelPayload) => {
|
||||||
@@ -1968,6 +1990,10 @@ onMounted(async () => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
updateCenterStore.unbind();
|
updateCenterStore.unbind();
|
||||||
|
window.ipcRenderer.off(
|
||||||
|
"install-complete",
|
||||||
|
handleInstallCompleteForDownloadRecord,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 观察器
|
// 观察器
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/vue";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import App from "@/App.vue";
|
||||||
|
import { recordDownloadedApp } from "@/modules/backendApi";
|
||||||
|
import { setAuthSession } from "@/global/authState";
|
||||||
|
import type { DownloadResult } from "@/global/typedefinition";
|
||||||
|
|
||||||
|
const invoke = vi.fn();
|
||||||
|
const send = vi.fn();
|
||||||
|
const ipcHandlers = new Map<string, (...args: unknown[]) => void>();
|
||||||
|
|
||||||
|
vi.mock("axios", () => {
|
||||||
|
const get = vi.fn(async (url: string) => {
|
||||||
|
if (url.includes("categories.json")) {
|
||||||
|
return { data: { office: { zh: "办公" } } };
|
||||||
|
}
|
||||||
|
if (url.includes("/office/applist.json")) {
|
||||||
|
return {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
Name: "WPS",
|
||||||
|
Pkgname: "wps",
|
||||||
|
Version: "1.0.0",
|
||||||
|
Filename: "wps_1.0.0_amd64.deb",
|
||||||
|
Torrent_address: "",
|
||||||
|
Author: "",
|
||||||
|
Contributor: "",
|
||||||
|
Website: "",
|
||||||
|
Update: "",
|
||||||
|
Size: "",
|
||||||
|
More: "Office suite",
|
||||||
|
Tags: "",
|
||||||
|
img_urls: "[]",
|
||||||
|
icons: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { data: [] };
|
||||||
|
});
|
||||||
|
const post = vi.fn(async () => ({ data: { ok: true } }));
|
||||||
|
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
create: () => ({ get, post }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/modules/updateCenter", () => ({
|
||||||
|
createUpdateCenterStore: () => ({
|
||||||
|
isOpen: { value: false },
|
||||||
|
showCloseConfirm: { value: false },
|
||||||
|
showMigrationConfirm: { value: false },
|
||||||
|
searchQuery: { value: "" },
|
||||||
|
selectedTaskKeys: { value: new Set<string>() },
|
||||||
|
snapshot: {
|
||||||
|
value: { items: [], tasks: [], warnings: [], hasRunningTasks: false },
|
||||||
|
},
|
||||||
|
filteredItems: { value: [] },
|
||||||
|
allSelected: { value: false },
|
||||||
|
someSelected: { value: false },
|
||||||
|
bind: vi.fn(),
|
||||||
|
unbind: vi.fn(),
|
||||||
|
open: vi.fn(),
|
||||||
|
refresh: vi.fn(),
|
||||||
|
ignoreItem: vi.fn(),
|
||||||
|
unignoreItem: vi.fn(),
|
||||||
|
toggleSelection: vi.fn(),
|
||||||
|
toggleSelectAll: vi.fn(),
|
||||||
|
getSelectedItems: vi.fn(() => []),
|
||||||
|
closeNow: vi.fn(),
|
||||||
|
startSelected: vi.fn(),
|
||||||
|
requestClose: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/modules/backendApi", () => ({
|
||||||
|
addFavoriteItem: vi.fn(),
|
||||||
|
bulkDeleteFavoriteItems: vi.fn(),
|
||||||
|
createFavoriteFolder: vi.fn(),
|
||||||
|
exchangeFlarumToken: vi.fn(),
|
||||||
|
listFavoriteFolders: vi.fn(async () => []),
|
||||||
|
listFavoriteItems: vi.fn(async () => []),
|
||||||
|
recordDownloadedApp: vi.fn(async () => undefined),
|
||||||
|
setBackendToken: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("App download records", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
ipcHandlers.clear();
|
||||||
|
invoke.mockImplementation(async (channel: string) => {
|
||||||
|
if (channel === "get-store-filter") return "apm";
|
||||||
|
if (channel === "check-spark-available") return false;
|
||||||
|
if (channel === "check-apm-available") return true;
|
||||||
|
if (channel === "get-app-version") return "5.0.0";
|
||||||
|
if (channel === "get-system-info") return { distro: "deepin 25" };
|
||||||
|
if (channel === "list-installed") return { success: true, apps: [] };
|
||||||
|
if (channel === "check-installed") return false;
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.assign(window.ipcRenderer, {
|
||||||
|
invoke,
|
||||||
|
send,
|
||||||
|
on: vi.fn((channel: string, handler: (...args: unknown[]) => void) => {
|
||||||
|
ipcHandlers.set(channel, handler);
|
||||||
|
}),
|
||||||
|
off: vi.fn(),
|
||||||
|
});
|
||||||
|
window.apm_store.arch = "amd64";
|
||||||
|
localStorage.clear();
|
||||||
|
setAuthSession({
|
||||||
|
accessToken: "backend-token",
|
||||||
|
tokenType: "bearer",
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
flarumUserId: "42",
|
||||||
|
username: "momen",
|
||||||
|
displayName: "Momen",
|
||||||
|
avatarUrl: "https://bbs.spark-app.store/avatar.png",
|
||||||
|
forumLevel: "管理员",
|
||||||
|
forumGroups: ["管理员"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
"matchMedia",
|
||||||
|
vi.fn(() => ({
|
||||||
|
matches: false,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("scrollTo", vi.fn());
|
||||||
|
class MockIntersectionObserver {
|
||||||
|
observe = vi.fn();
|
||||||
|
disconnect = vi.fn();
|
||||||
|
unobserve = vi.fn();
|
||||||
|
}
|
||||||
|
vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records a download only after the queued install completes successfully", async () => {
|
||||||
|
render(App);
|
||||||
|
|
||||||
|
await fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: "全部应用 1" }),
|
||||||
|
);
|
||||||
|
await fireEvent.click(await screen.findByText("WPS"));
|
||||||
|
await fireEvent.click(await screen.findByRole("button", { name: "安装" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(send).toHaveBeenCalledWith(
|
||||||
|
"queue-install",
|
||||||
|
expect.stringContaining('"pkgname":"wps"'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(recordDownloadedApp).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const queuedPayload = vi
|
||||||
|
.mocked(send)
|
||||||
|
.mock.calls.find(
|
||||||
|
([channel]) => channel === "queue-install",
|
||||||
|
)?.[1] as string;
|
||||||
|
const queuedDownload = JSON.parse(queuedPayload) as { id: number };
|
||||||
|
const completion: DownloadResult = {
|
||||||
|
id: queuedDownload.id,
|
||||||
|
time: Date.now(),
|
||||||
|
message: "installed",
|
||||||
|
success: true,
|
||||||
|
exitCode: 0,
|
||||||
|
status: "completed",
|
||||||
|
origin: "apm",
|
||||||
|
};
|
||||||
|
|
||||||
|
ipcHandlers.get("install-complete")?.({}, completion);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(recordDownloadedApp).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
appKey: "app:office:wps",
|
||||||
|
pkgname: "wps",
|
||||||
|
selectedOrigin: "apm",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,17 @@
|
|||||||
import { fireEvent, render, screen } from "@testing-library/vue";
|
import { fireEvent, render, screen } from "@testing-library/vue";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import AppDetailPage from "@/components/AppDetailPage.vue";
|
import AppDetailPage from "@/components/AppDetailPage.vue";
|
||||||
import type { App } from "@/global/typedefinition";
|
import type { App, ReviewTags } from "@/global/typedefinition";
|
||||||
|
|
||||||
|
vi.mock("@/components/ReviewsPanel.vue", () => ({
|
||||||
|
default: {
|
||||||
|
name: "ReviewsPanel",
|
||||||
|
props: ["appKey", "tags", "loggedIn"],
|
||||||
|
template:
|
||||||
|
'<div data-testid="reviews-panel" :data-app-key="appKey" :data-origin="tags.origin" :data-version="tags.version"></div>',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
const app: App = {
|
const app: App = {
|
||||||
name: "WPS",
|
name: "WPS",
|
||||||
@@ -24,6 +33,40 @@ const app: App = {
|
|||||||
currentStatus: "not-installed",
|
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",
|
||||||
|
version: "1.0.0",
|
||||||
|
filename: "wps_1.0.0_amd64.deb",
|
||||||
|
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("AppDetailPage", () => {
|
describe("AppDetailPage", () => {
|
||||||
it("renders as page, emits back, and gates favorite for anonymous users", async () => {
|
it("renders as page, emits back, and gates favorite for anonymous users", async () => {
|
||||||
const rendered = render(AppDetailPage, {
|
const rendered = render(AppDetailPage, {
|
||||||
@@ -47,4 +90,42 @@ describe("AppDetailPage", () => {
|
|||||||
"收藏应用需要登录星火账号。",
|
"收藏应用需要登录星火账号。",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("updates review identity when switching a merged app origin", async () => {
|
||||||
|
render(AppDetailPage, {
|
||||||
|
props: {
|
||||||
|
app: mergedApp,
|
||||||
|
screenshots: [],
|
||||||
|
sparkInstalled: false,
|
||||||
|
apmInstalled: false,
|
||||||
|
loggedIn: false,
|
||||||
|
reviewAppKey: "spark:amd64-store:office:wps",
|
||||||
|
reviewTags: sparkTags,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
|
||||||
|
"data-app-key",
|
||||||
|
"spark:amd64-store:office:wps",
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
|
||||||
|
"data-origin",
|
||||||
|
"spark",
|
||||||
|
);
|
||||||
|
|
||||||
|
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",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,29 @@
|
|||||||
import { render, screen } from "@testing-library/vue";
|
import { render, screen } from "@testing-library/vue";
|
||||||
import { describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import ReviewsPanel from "@/components/ReviewsPanel.vue";
|
import ReviewsPanel from "@/components/ReviewsPanel.vue";
|
||||||
import type { ReviewTags } from "@/global/typedefinition";
|
import {
|
||||||
|
fetchRatingSummary,
|
||||||
|
fetchReviews,
|
||||||
|
submitReview,
|
||||||
|
} from "@/modules/backendApi";
|
||||||
|
import type {
|
||||||
|
AppReview,
|
||||||
|
RatingSummary,
|
||||||
|
ReviewTags,
|
||||||
|
} from "@/global/typedefinition";
|
||||||
|
|
||||||
|
const emptySummary: RatingSummary = {
|
||||||
|
averageRating: 0,
|
||||||
|
reviewCount: 0,
|
||||||
|
starCounts: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("@/modules/backendApi", () => ({
|
||||||
|
fetchRatingSummary: vi.fn(async () => emptySummary),
|
||||||
|
fetchReviews: vi.fn(async () => []),
|
||||||
|
submitReview: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
const tags: ReviewTags = {
|
const tags: ReviewTags = {
|
||||||
origin: "apm",
|
origin: "apm",
|
||||||
@@ -15,6 +36,14 @@ const tags: ReviewTags = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("ReviewsPanel", () => {
|
describe("ReviewsPanel", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(fetchRatingSummary).mockReset();
|
||||||
|
vi.mocked(fetchReviews).mockReset();
|
||||||
|
vi.mocked(submitReview).mockReset();
|
||||||
|
vi.mocked(fetchRatingSummary).mockResolvedValue(emptySummary);
|
||||||
|
vi.mocked(fetchReviews).mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("shows anonymous login prompt and read-only review tags", () => {
|
it("shows anonymous login prompt and read-only review tags", () => {
|
||||||
render(ReviewsPanel, {
|
render(ReviewsPanel, {
|
||||||
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: false },
|
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: false },
|
||||||
@@ -24,4 +53,88 @@ describe("ReviewsPanel", () => {
|
|||||||
expect(screen.getByText("1.0.0")).toBeTruthy();
|
expect(screen.getByText("1.0.0")).toBeTruthy();
|
||||||
expect(screen.getByText("deepin 25")).toBeTruthy();
|
expect(screen.getByText("deepin 25")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("ignores stale review responses after app key changes", async () => {
|
||||||
|
let resolveFirstSummary!: (summary: RatingSummary) => void;
|
||||||
|
let resolveFirstReviews!: (reviews: AppReview[]) => void;
|
||||||
|
let resolveSecondSummary!: (summary: RatingSummary) => void;
|
||||||
|
let resolveSecondReviews!: (reviews: AppReview[]) => void;
|
||||||
|
|
||||||
|
vi.mocked(fetchRatingSummary)
|
||||||
|
.mockReturnValueOnce(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolveFirstSummary = resolve;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockReturnValueOnce(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolveSecondSummary = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.mocked(fetchReviews)
|
||||||
|
.mockReturnValueOnce(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolveFirstReviews = resolve;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockReturnValueOnce(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolveSecondReviews = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const rendered = render(ReviewsPanel, {
|
||||||
|
props: { appKey: "first", tags, loggedIn: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
await rendered.rerender({ appKey: "second", tags, loggedIn: false });
|
||||||
|
|
||||||
|
resolveSecondSummary({ averageRating: 5, reviewCount: 1, starCounts: {} });
|
||||||
|
resolveSecondReviews([
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
rating: 5,
|
||||||
|
content: "second review",
|
||||||
|
version: tags.version,
|
||||||
|
packageArch: tags.packageArch,
|
||||||
|
clientArch: tags.clientArch,
|
||||||
|
distro: tags.distro,
|
||||||
|
origin: tags.origin,
|
||||||
|
category: tags.category,
|
||||||
|
createdAt: "2026-05-18T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-18T00:00:00Z",
|
||||||
|
userDisplayName: "Second User",
|
||||||
|
userAvatarUrl: "",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(await screen.findByText("second review")).toBeTruthy();
|
||||||
|
expect(screen.getByText("5.0 / 5 (1)")).toBeTruthy();
|
||||||
|
|
||||||
|
resolveFirstSummary({ averageRating: 1, reviewCount: 1, starCounts: {} });
|
||||||
|
resolveFirstReviews([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
rating: 1,
|
||||||
|
content: "first review",
|
||||||
|
version: tags.version,
|
||||||
|
packageArch: tags.packageArch,
|
||||||
|
clientArch: tags.clientArch,
|
||||||
|
distro: tags.distro,
|
||||||
|
origin: tags.origin,
|
||||||
|
category: tags.category,
|
||||||
|
createdAt: "2026-05-18T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-18T00:00:00Z",
|
||||||
|
userDisplayName: "First User",
|
||||||
|
userAvatarUrl: "",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(screen.getByText("second review")).toBeTruthy();
|
||||||
|
expect(screen.getByText("5.0 / 5 (1)")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("first review")).toBeNull();
|
||||||
|
expect(screen.queryByText("1.0 / 5 (1)")).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -214,7 +214,11 @@ import {
|
|||||||
APM_STORE_BASE_URL,
|
APM_STORE_BASE_URL,
|
||||||
getHybridDefaultOrigin,
|
getHybridDefaultOrigin,
|
||||||
} from "@/global/storeConfig";
|
} from "@/global/storeConfig";
|
||||||
import { getDisplayApp } from "@/modules/appIdentity";
|
import {
|
||||||
|
buildReviewAppKey,
|
||||||
|
buildReviewTags,
|
||||||
|
getDisplayApp,
|
||||||
|
} from "@/modules/appIdentity";
|
||||||
import type { App, ReviewTags } from "@/global/typedefinition";
|
import type { App, ReviewTags } from "@/global/typedefinition";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -299,6 +303,22 @@ const detailHtml = computed(
|
|||||||
() => displayApp.value?.more.replace(/\n/g, "<br>") ?? "",
|
() => displayApp.value?.more.replace(/\n/g, "<br>") ?? "",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const reviewAppKey = computed(() => {
|
||||||
|
if (!displayApp.value) return "";
|
||||||
|
return buildReviewAppKey(
|
||||||
|
displayApp.value,
|
||||||
|
props.reviewTags?.clientArch ?? "amd64",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const reviewTags = computed<ReviewTags | null>(() => {
|
||||||
|
if (!displayApp.value || !props.reviewTags) return null;
|
||||||
|
return buildReviewTags(displayApp.value, {
|
||||||
|
clientArch: props.reviewTags.clientArch,
|
||||||
|
distro: props.reviewTags.distro,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const selectOrigin = (origin: "spark" | "apm") => {
|
const selectOrigin = (origin: "spark" | "apm") => {
|
||||||
viewingOrigin.value = origin;
|
viewingOrigin.value = origin;
|
||||||
if (displayApp.value) emit("check-install", displayApp.value);
|
if (displayApp.value) emit("check-install", displayApp.value);
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ const summary = ref<RatingSummary | null>(null);
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
const error = ref("");
|
const error = ref("");
|
||||||
|
const loadGeneration = ref(0);
|
||||||
|
|
||||||
const ratingText = computed(() => {
|
const ratingText = computed(() => {
|
||||||
if (!summary.value || summary.value.reviewCount === 0) return "暂无评分";
|
if (!summary.value || summary.value.reviewCount === 0) return "暂无评分";
|
||||||
@@ -153,37 +154,50 @@ const ratingText = computed(() => {
|
|||||||
|
|
||||||
const loadReviews = async () => {
|
const loadReviews = async () => {
|
||||||
if (!props.appKey) return;
|
if (!props.appKey) return;
|
||||||
|
const generation = loadGeneration.value + 1;
|
||||||
|
loadGeneration.value = generation;
|
||||||
|
const appKey = props.appKey;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
try {
|
try {
|
||||||
const [nextSummary, nextReviews] = await Promise.all([
|
const [nextSummary, nextReviews] = await Promise.all([
|
||||||
fetchRatingSummary(props.appKey),
|
fetchRatingSummary(appKey),
|
||||||
fetchReviews(props.appKey),
|
fetchReviews(appKey),
|
||||||
]);
|
]);
|
||||||
|
if (generation !== loadGeneration.value || appKey !== props.appKey) return;
|
||||||
summary.value = nextSummary;
|
summary.value = nextSummary;
|
||||||
reviews.value = nextReviews;
|
reviews.value = nextReviews;
|
||||||
} catch (caught: unknown) {
|
} catch (caught: unknown) {
|
||||||
|
if (generation !== loadGeneration.value || appKey !== props.appKey) return;
|
||||||
error.value = (caught as Error)?.message || "加载评价失败";
|
error.value = (caught as Error)?.message || "加载评价失败";
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
if (generation === loadGeneration.value && appKey === props.appKey) {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
|
const appKey = props.appKey;
|
||||||
|
const tags = props.tags;
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
try {
|
try {
|
||||||
await submitReview(props.appKey, {
|
await submitReview(appKey, {
|
||||||
rating: rating.value,
|
rating: rating.value,
|
||||||
content: content.value.trim(),
|
content: content.value.trim(),
|
||||||
tags: props.tags,
|
tags,
|
||||||
});
|
});
|
||||||
|
if (appKey !== props.appKey) return;
|
||||||
content.value = "";
|
content.value = "";
|
||||||
await loadReviews();
|
await loadReviews();
|
||||||
} catch (caught: unknown) {
|
} catch (caught: unknown) {
|
||||||
|
if (appKey !== props.appKey) return;
|
||||||
error.value = (caught as Error)?.message || "发表评论失败";
|
error.value = (caught as Error)?.message || "发表评论失败";
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false;
|
if (appKey === props.appKey) {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user