feat(account): polish reviews favorites and account UI

This commit is contained in:
2026-05-29 21:34:42 +08:00
parent abeb511c06
commit 439af8c26f
40 changed files with 3158 additions and 250 deletions
+464
View File
@@ -3,12 +3,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import ReviewsPanel from "@/components/ReviewsPanel.vue";
import {
createReviewReply,
deleteReview,
deleteReviewReply,
fetchRatingSummary,
fetchReviews,
likeReview,
likeReviewReply,
submitReview,
} from "@/modules/backendApi";
import type {
AppReview,
AppReviewReply,
RatingSummary,
ReviewTags,
} from "@/global/typedefinition";
@@ -20,8 +26,13 @@ const emptySummary: RatingSummary = {
};
vi.mock("@/modules/backendApi", () => ({
createReviewReply: vi.fn(),
deleteReview: vi.fn(),
deleteReviewReply: vi.fn(),
fetchRatingSummary: vi.fn(async () => emptySummary),
fetchReviews: vi.fn(async () => []),
likeReview: vi.fn(),
likeReviewReply: vi.fn(),
submitReview: vi.fn(),
}));
@@ -35,13 +46,70 @@ const tags: ReviewTags = {
distro: "deepin 25",
};
const makeReview = (overrides: Partial<AppReview>): AppReview => ({
id: 1,
rating: 5,
content: "默认评价",
version: tags.version,
packageArch: tags.packageArch,
clientArch: tags.clientArch,
distro: tags.distro,
origin: tags.origin,
category: tags.category,
createdAt: "2026-05-20T00:00:00Z",
updatedAt: "2026-05-20T00:00:00Z",
userDisplayName: "星火用户",
userAvatarUrl: "",
likeCount: 0,
likedByCurrentUser: false,
canDelete: false,
isAuthor: false,
isDeleted: false,
replies: [],
...overrides,
});
const makeReply = (overrides: Partial<AppReviewReply>): AppReviewReply => ({
id: 101,
reviewId: 1,
parentId: null,
content: "默认回复",
createdAt: "2026-05-20T00:00:00Z",
updatedAt: "2026-05-20T00:00:00Z",
userDisplayName: "回复用户",
userAvatarUrl: "",
likeCount: 0,
likedByCurrentUser: false,
canDelete: false,
isAuthor: false,
isDeleted: false,
replies: [],
...overrides,
});
describe("ReviewsPanel", () => {
beforeEach(() => {
vi.mocked(fetchRatingSummary).mockReset();
vi.mocked(fetchReviews).mockReset();
vi.mocked(submitReview).mockReset();
vi.mocked(likeReview).mockReset();
vi.mocked(createReviewReply).mockReset();
vi.mocked(deleteReview).mockReset();
vi.mocked(likeReviewReply).mockReset();
vi.mocked(deleteReviewReply).mockReset();
vi.mocked(fetchRatingSummary).mockResolvedValue(emptySummary);
vi.mocked(fetchReviews).mockResolvedValue([]);
vi.mocked(likeReview).mockResolvedValue({
likedByCurrentUser: true,
likeCount: 1,
});
vi.mocked(createReviewReply).mockResolvedValue(makeReply({}));
vi.mocked(deleteReview).mockResolvedValue(undefined);
vi.mocked(likeReviewReply).mockResolvedValue({
likedByCurrentUser: true,
likeCount: 1,
});
vi.mocked(deleteReviewReply).mockResolvedValue(undefined);
});
it("shows anonymous login prompt and read-only review tags", () => {
@@ -171,6 +239,402 @@ describe("ReviewsPanel", () => {
).toBeTruthy();
});
it("shows a re-login prompt when loading reviews with a stale token", async () => {
vi.mocked(fetchRatingSummary).mockRejectedValueOnce(
new Error("Request failed with status code 401"),
);
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
expect(
await screen.findByText("登录状态已失效,请重新登录星火账号。"),
).toBeTruthy();
});
it("submits the rating selected by sliding over stars", async () => {
vi.mocked(submitReview).mockResolvedValueOnce(
makeReview({ rating: 3, content: "一般" }),
);
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
const slider = screen.getByRole("slider", { name: "评分" });
vi.spyOn(slider, "getBoundingClientRect").mockReturnValue({
x: 0,
y: 0,
width: 500,
height: 40,
top: 0,
left: 0,
right: 500,
bottom: 40,
toJSON: () => ({}),
});
const pointerDown = new MouseEvent("pointerdown", {
bubbles: true,
clientX: 250,
});
const pointerMove = new MouseEvent("pointermove", {
bubbles: true,
clientX: 250,
});
const pointerUp = new MouseEvent("pointerup", {
bubbles: true,
clientX: 250,
});
Object.defineProperty(pointerDown, "pointerId", { value: 1 });
Object.defineProperty(pointerMove, "pointerId", { value: 1 });
Object.defineProperty(pointerUp, "pointerId", { value: 1 });
await fireEvent(slider, pointerDown);
await fireEvent(slider, pointerMove);
await fireEvent(slider, pointerUp);
await fireEvent.update(
screen.getByPlaceholderText("分享你的使用体验"),
"一般",
);
await fireEvent.click(screen.getByRole("button", { name: "发表评论" }));
expect(submitReview).toHaveBeenCalledWith(
"apm:amd64-apm:office:wps",
expect.objectContaining({ rating: 3 }),
);
});
it("updates rating preview from hovering over the star hitbox", async () => {
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
const slider = screen.getByRole("slider", { name: "评分" });
vi.spyOn(slider, "getBoundingClientRect").mockReturnValue({
x: 0,
y: 0,
width: 500,
height: 40,
top: 0,
left: 0,
right: 500,
bottom: 40,
toJSON: () => ({}),
});
const pointerMove = new MouseEvent("pointermove", {
bubbles: true,
clientX: 250,
});
Object.defineProperty(pointerMove, "pointerId", { value: 1 });
await fireEvent(slider, pointerMove);
expect(slider).toHaveAttribute("aria-valuenow", "3");
});
it("does not include a trailing rating label in the star slider hitbox", () => {
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
const slider = screen.getByRole("slider", { name: "评分" });
expect(slider).not.toHaveTextContent("星");
expect(slider).not.toHaveClass("border");
expect(slider).not.toHaveClass("bg-amber-50");
});
it("supports keyboard changes for the sliding star rating", async () => {
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
const slider = screen.getByRole("slider", { name: "评分" });
await fireEvent.keyDown(slider, { key: "ArrowLeft" });
await fireEvent.keyDown(slider, { key: "ArrowLeft" });
expect(slider).toHaveAttribute("aria-valuenow", "3");
});
it("filters loaded reviews by package architecture and distro", async () => {
vi.mocked(fetchReviews).mockResolvedValue([
{
id: 11,
rating: 5,
content: "amd64 deepin",
version: tags.version,
packageArch: "amd64",
clientArch: tags.clientArch,
distro: "deepin 25",
origin: tags.origin,
category: tags.category,
createdAt: "2026-05-20T00:00:00Z",
updatedAt: "2026-05-20T00:00:00Z",
userDisplayName: "Deepin User",
userAvatarUrl: "",
},
{
id: 12,
rating: 4,
content: "arm64 gxde",
version: tags.version,
packageArch: "arm64",
clientArch: tags.clientArch,
distro: "GXDE OS 25",
origin: tags.origin,
category: tags.category,
createdAt: "2026-05-20T00:00:00Z",
updatedAt: "2026-05-20T00:00:00Z",
userDisplayName: "GXDE User",
userAvatarUrl: "",
},
]);
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
expect(await screen.findByText("amd64 deepin")).toBeTruthy();
expect(screen.getByText("arm64 gxde")).toBeTruthy();
await fireEvent.change(screen.getByLabelText("按架构筛选"), {
target: { value: "arm64" },
});
expect(screen.queryByText("amd64 deepin")).toBeNull();
expect(screen.getByText("arm64 gxde")).toBeTruthy();
await fireEvent.change(screen.getByLabelText("按架构筛选"), {
target: { value: "" },
});
await fireEvent.change(screen.getByLabelText("按发行版筛选"), {
target: { value: "GXDE OS 25" },
});
expect(screen.queryByText("amd64 deepin")).toBeNull();
expect(screen.getByText("arm64 gxde")).toBeTruthy();
});
it("resets stale review filters when the app key changes", async () => {
vi.mocked(fetchReviews)
.mockResolvedValueOnce([
makeReview({ id: 21, content: "first amd64", packageArch: "amd64" }),
makeReview({
id: 22,
content: "first arm64",
packageArch: "arm64",
distro: "GXDE OS 25",
}),
])
.mockResolvedValueOnce([
makeReview({
id: 23,
content: "second only amd64",
packageArch: "amd64",
}),
]);
const rendered = render(ReviewsPanel, {
props: { appKey: "first", tags, loggedIn: true },
});
expect(await screen.findByText("first amd64")).toBeTruthy();
await fireEvent.change(screen.getByLabelText("按架构筛选"), {
target: { value: "arm64" },
});
expect(screen.queryByText("first amd64")).toBeNull();
expect(screen.getByText("first arm64")).toBeTruthy();
await rendered.rerender({ appKey: "second", tags, loggedIn: true });
expect(await screen.findByText("second only amd64")).toBeTruthy();
expect(screen.queryByText("没有符合筛选条件的评价")).toBeNull();
});
it("exposes reviewer detail affordances from avatar and name buttons", async () => {
vi.mocked(fetchReviews).mockResolvedValue([
makeReview({
id: 31,
content: "用户资料入口",
userDisplayName: "Detail User",
}),
]);
const { emitted } = render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
await fireEvent.click(
await screen.findByRole("button", { name: "查看Detail User的资料" }),
);
expect(screen.getByText("正在查看 Detail User 的资料")).toBeTruthy();
await fireEvent.click(screen.getByRole("button", { name: "Detail User" }));
expect(emitted()["show-user"]).toHaveLength(2);
});
it("calls backend review actions and only shows delete from backend metadata", async () => {
vi.mocked(fetchReviews).mockResolvedValue([
makeReview({ id: 41, content: "普通评价", userDisplayName: "Reader" }),
makeReview({
id: 42,
content: "作者评价",
userDisplayName: "Author",
isAuthor: true,
canDelete: true,
}),
]);
const rendered = render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
expect(await screen.findByText("普通评价")).toBeTruthy();
expect(screen.getAllByRole("button", { name: "点赞" })).toHaveLength(2);
expect(screen.getAllByRole("button", { name: "回复" })).toHaveLength(2);
expect(screen.getAllByRole("button", { name: "删除" })).toHaveLength(1);
await fireEvent.click(screen.getAllByRole("button", { name: "点赞" })[0]);
expect(likeReview).toHaveBeenCalledWith("apm:amd64-apm:office:wps", 41);
expect(fetchReviews).toHaveBeenCalledTimes(2);
expect(await screen.findByText("作者评价")).toBeTruthy();
await fireEvent.click(screen.getAllByRole("button", { name: "删除" })[0]);
expect(deleteReview).toHaveBeenCalledWith("apm:amd64-apm:office:wps", 42);
await rendered.rerender({
appKey: "apm:amd64-apm:office:wps",
tags,
loggedIn: true,
currentUserIsAdmin: true,
});
expect(screen.getAllByRole("button", { name: "删除" })).toHaveLength(1);
});
it("creates review replies and nested replies through backend APIs", async () => {
vi.mocked(fetchReviews).mockResolvedValue([
makeReview({
id: 51,
content: "可回复评价",
replies: [
makeReply({
id: 151,
reviewId: 51,
content: "已有回复",
replies: [
makeReply({
id: 152,
reviewId: 51,
parentId: 151,
content: "已有二级回复",
}),
],
}),
],
}),
]);
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
await fireEvent.click(
(await screen.findAllByRole("button", { name: "回复" }))[0],
);
expect(screen.getByText("已有二级回复")).toBeTruthy();
await fireEvent.update(
screen.getByPlaceholderText("写下你的回复"),
"一级回复",
);
await fireEvent.click(screen.getByRole("button", { name: "发送回复" }));
expect(createReviewReply).toHaveBeenCalledWith(
"apm:amd64-apm:office:wps",
51,
{ content: "一级回复" },
);
expect(await screen.findByText("已有回复")).toBeTruthy();
await fireEvent.click(screen.getAllByRole("button", { name: "回复" })[1]);
await fireEvent.update(
screen.getByPlaceholderText("写下你的回复"),
"二级回复",
);
await fireEvent.click(screen.getByRole("button", { name: "发送回复" }));
expect(createReviewReply).toHaveBeenCalledWith(
"apm:amd64-apm:office:wps",
51,
{ content: "二级回复", parentId: 151 },
);
});
it("calls backend reply actions and shows permission errors", async () => {
vi.mocked(deleteReview).mockRejectedValueOnce(
new Error("请登录星火账号后重试。"),
);
vi.mocked(fetchReviews).mockResolvedValue([
makeReview({
id: 61,
content: "带回复评价",
canDelete: true,
replies: [
makeReply({
id: 161,
reviewId: 61,
content: "可操作回复",
canDelete: true,
}),
],
}),
]);
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
expect(await screen.findByText("可操作回复")).toBeTruthy();
await fireEvent.click(screen.getAllByRole("button", { name: "点赞" })[1]);
expect(likeReviewReply).toHaveBeenCalledWith(
"apm:amd64-apm:office:wps",
61,
161,
);
expect(await screen.findByText("可操作回复")).toBeTruthy();
await fireEvent.click(screen.getAllByRole("button", { name: "删除" })[0]);
expect(await screen.findByText("请登录星火账号后重试。"));
await fireEvent.click(screen.getAllByRole("button", { name: "删除" })[1]);
expect(deleteReviewReply).toHaveBeenCalledWith(
"apm:amd64-apm:office:wps",
61,
161,
);
});
it("preserves stale-token prompts from review action failures", async () => {
vi.mocked(likeReview).mockRejectedValueOnce(
new Error("登录状态已失效,请重新登录星火账号。"),
);
vi.mocked(fetchReviews).mockResolvedValue([
makeReview({ id: 71, content: "旧登录态评价" }),
]);
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
expect(await screen.findByText("旧登录态评价")).toBeTruthy();
await fireEvent.click(screen.getByRole("button", { name: "点赞" }));
expect(
await screen.findByText("登录状态已失效,请重新登录星火账号。"),
).toBeTruthy();
});
it("shows reviewer avatars when available", async () => {
vi.mocked(fetchRatingSummary).mockResolvedValue({
averageRating: 5,