实现网站游戏评分与评价 (#561)
Project CI / AI game creator shell Rust crates (push) Successful in 1m43s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 2m7s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m55s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 3m23s
Project CI / Frontend tests (push) Successful in 3m3s
Project CI / Repository checks (push) Successful in 3m42s
Project CI / AI game creator shell web tests (push) Successful in 2m3s
Project CI / Backend tests (push) Successful in 6m19s
Project CI / Native shell tests (push) Successful in 8m7s
Project CI / AI game creator shell Rust crates (push) Successful in 1m43s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 2m7s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m55s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 3m23s
Project CI / Frontend tests (push) Successful in 3m3s
Project CI / Repository checks (push) Successful in 3m42s
Project CI / AI game creator shell web tests (push) Successful in 2m3s
Project CI / Backend tests (push) Successful in 6m19s
Project CI / Native shell tests (push) Successful in 8m7s
公开游戏详情页新增评分与评价:登录用户可提交并修改自己对每款游戏的唯一一条 1–10 分评价,评论可空且最多 4000 个 Unicode 码点;游客可分页浏览评价并查看平均分与评分人数。 - 新增私有评价表及共享 DTO/API,事务内校验游戏公开可见性、保证唯一性,并计算分页与全量评分统计;下架或封禁后关闭读写,重新公开及更新版本保留评价。 - 详情页支持编辑预填、取消、提交期间冻结输入、失败保留草稿,以及保存、翻页和账号/游戏切换时的旧响应隔离;复用现有认证与公共组件。 - 同步主规范、里程碑、实施计划、生成绑定、表目录和验收脚本。不扩展 AGC、外部 API、评论审核或评分缓存。 验证: - 本轮审查复跑前端定向测试 40 项、评价领域测试 2 项,均通过。 - 网站 typecheck、Rust/TypeScript DTO 对齐、SpacetimeDB schema、文档索引、编码与 diff 检查通过。 - 实施文档另记录本地隔离数据库/API 49 项及桌面/移动视口浏览器 smoke 通过;本轮审查未重跑这些运行时验收,也未运行完整测试套件。 发布说明:新增 SpacetimeDB 表,部署时需同步 module 与 API。尚未部署 dev/production,待用户验收。 Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/561 Co-authored-by: Linghong <ink29535@proton.me> Co-committed-by: Linghong <ink29535@proton.me>
This commit was merged in pull request #561.
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
||||
type GameDistributionInputMode,
|
||||
getGame,
|
||||
} from '../../services/gameDistributionClient';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import {
|
||||
@@ -24,6 +25,8 @@ import {
|
||||
useIsMobileViewport,
|
||||
} from './gameDistributionGuards';
|
||||
import { GameCover } from './GameGalleryPage';
|
||||
import { GameRatingSummary, GameReviews } from './GameReviews';
|
||||
import { useGameReviews } from './useGameReviews';
|
||||
|
||||
type GameDetailPageProps = {
|
||||
gameId: string | null;
|
||||
@@ -130,6 +133,7 @@ export function GameDetailPage({
|
||||
onBack,
|
||||
onPlay,
|
||||
}: GameDetailPageProps) {
|
||||
const authUi = useAuthUi();
|
||||
const [game, setGame] = useState<GameDistributionGame | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
@@ -183,7 +187,7 @@ export function GameDetailPage({
|
||||
};
|
||||
}, [loadGame]);
|
||||
|
||||
if (isLoading)
|
||||
if (isLoading || (game && game.id !== normalizeGameId(gameId)))
|
||||
return (
|
||||
<div className="game-detail-page">
|
||||
<div className="game-loading-panel">正在加载游戏详情…</div>
|
||||
@@ -208,6 +212,36 @@ export function GameDetailPage({
|
||||
</div>
|
||||
);
|
||||
|
||||
const canReview = Boolean(authUi?.user && authUi.canAccessProtectedData);
|
||||
return (
|
||||
<GameDetailContent
|
||||
key={`${game.id}:${authUi?.user?.id ?? 'guest'}:${canReview}`}
|
||||
game={game}
|
||||
isMobileViewport={isMobileViewport}
|
||||
canReview={canReview}
|
||||
onLogin={() => authUi?.openLoginModal()}
|
||||
onBack={onBack}
|
||||
onPlay={onPlay}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function GameDetailContent({
|
||||
game,
|
||||
isMobileViewport,
|
||||
canReview,
|
||||
onLogin,
|
||||
onBack,
|
||||
onPlay,
|
||||
}: {
|
||||
game: GameDistributionGame;
|
||||
isMobileViewport: boolean;
|
||||
canReview: boolean;
|
||||
onLogin: () => void;
|
||||
onBack: () => void;
|
||||
onPlay: (gameId: string) => void;
|
||||
}) {
|
||||
const reviews = useGameReviews(game.id, canReview);
|
||||
const version = game.currentVersion;
|
||||
const mobilePlaybackBlocked = isMobileViewport && !game.deviceSupport.mobile;
|
||||
const playbackBlocked = !version || mobilePlaybackBlocked;
|
||||
@@ -224,6 +258,9 @@ export function GameDetailPage({
|
||||
</span>
|
||||
<h1>{game.title}</h1>
|
||||
<p className="game-detail-hero__summary">{game.summary}</p>
|
||||
<div className="game-detail-hero__rating">
|
||||
<GameRatingSummary state={reviews} />
|
||||
</div>
|
||||
<div className="game-detail-hero__author">
|
||||
<span className="game-avatar">{game.author.name.slice(0, 1)}</span>
|
||||
<span>由 {game.author.name} 制作</span>
|
||||
@@ -287,6 +324,7 @@ export function GameDetailPage({
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
<GameReviews state={reviews} canReview={canReview} onLogin={onLogin} />
|
||||
<p className="game-detail-footnote">
|
||||
<Maximize2 aria-hidden="true" />{' '}
|
||||
游玩时可以切换全屏,游戏运行在独立的安全容器中。
|
||||
|
||||
@@ -23,6 +23,14 @@ const resolvedAssetUrlMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../../services/gameDistributionClient', () => ({
|
||||
listGames: listGamesMock,
|
||||
getGame: getGameMock,
|
||||
listGameReviews: vi.fn().mockResolvedValue({
|
||||
reviews: [],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
ratingSummary: { averageScore: null, ratingCount: 0 },
|
||||
}),
|
||||
}));
|
||||
|
||||
// 只替换换签结果,测试不发起任何真实请求;返回空串即代表签名失败或字段为空。
|
||||
@@ -65,6 +73,7 @@ function createGame(
|
||||
author: { id: 'author-1', name: '测试作者' },
|
||||
deviceSupport: { desktop: true, mobile: true, touch: true },
|
||||
status: 'published',
|
||||
publicationRevision: 1,
|
||||
currentVersion: {
|
||||
id: 'version-1',
|
||||
version: '1.0.0',
|
||||
@@ -111,6 +120,18 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('GameGalleryPage', () => {
|
||||
it.each([
|
||||
[{ averageScore: 8.2, ratingCount: 26 }, '8.2/10 · 26 人评分'],
|
||||
[{ averageScore: 9, ratingCount: 1 }, '9.0/10 · 1 人评分'],
|
||||
[{ averageScore: null, ratingCount: 0 }, '暂无评分'],
|
||||
[undefined, '评分暂不可用'],
|
||||
])('显示列表返回的评分摘要 %#', async (ratingSummary, expected) => {
|
||||
listGamesMock.mockResolvedValueOnce([createGame({ ratingSummary })]);
|
||||
render(<GameGalleryPage onOpenDetail={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText(expected)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('ignores a stale response after search keyword changes', async () => {
|
||||
const first = deferred<GameDistributionGame[]>();
|
||||
const second = deferred<GameDistributionGame[]>();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import './gameDistribution.css';
|
||||
import './gameDistributionShowcase.css';
|
||||
|
||||
import { GameRatingSummaryText } from '@genarrative/shared/components';
|
||||
import { Gamepad2, Monitor, Search, Smartphone, Sparkles } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
@@ -104,6 +105,9 @@ function GameCard({
|
||||
</span>
|
||||
</span>
|
||||
<span className="game-card__summary">{game.summary}</span>
|
||||
<span className="game-card__rating">
|
||||
<GameRatingSummaryText summary={game.ratingSummary} />
|
||||
</span>
|
||||
<span className="game-card__meta">
|
||||
<span>{game.category}</span>
|
||||
<span>·</span>
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
/* @vitest-environment jsdom */
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
GameDistributionReview,
|
||||
GameDistributionReviewsResponse,
|
||||
} from '../../services/gameDistributionClient';
|
||||
import { GameDetailPage } from './GameDetailPage';
|
||||
import { GameReviews } from './GameReviews';
|
||||
import { reviewCommentLength, useGameReviews } from './useGameReviews';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
mine: vi.fn(),
|
||||
save: vi.fn(),
|
||||
game: vi.fn(),
|
||||
login: vi.fn(),
|
||||
userId: 'user-1' as string | null,
|
||||
}));
|
||||
vi.mock('../../services/gameDistributionClient', () => ({
|
||||
listGameReviews: mocks.list,
|
||||
getMyGameReview: mocks.mine,
|
||||
saveMyGameReview: mocks.save,
|
||||
getGame: mocks.game,
|
||||
}));
|
||||
vi.mock('../auth/AuthUiContext', () => ({
|
||||
useAuthUi: () => ({
|
||||
user: mocks.userId ? { id: mocks.userId } : null,
|
||||
canAccessProtectedData: !!mocks.userId,
|
||||
openLoginModal: mocks.login,
|
||||
}),
|
||||
}));
|
||||
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
|
||||
useResolvedAssetReadUrl: () => ({ resolvedUrl: '' }),
|
||||
}));
|
||||
|
||||
function review(
|
||||
overrides: Partial<GameDistributionReview> = {},
|
||||
): GameDistributionReview {
|
||||
return {
|
||||
id: 'review-1',
|
||||
gameId: 'game-1',
|
||||
author: { id: 'user-1', name: '用户一' },
|
||||
score: 8,
|
||||
isHidden: false,
|
||||
comment: '原评论',
|
||||
createdAt: '2026-09-30T00:00:00Z',
|
||||
updatedAt: '2026-09-30T00:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
function list(
|
||||
reviews: GameDistributionReview[] = [],
|
||||
overrides: Partial<GameDistributionReviewsResponse> = {},
|
||||
): GameDistributionReviewsResponse {
|
||||
return {
|
||||
reviews,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: reviews.length,
|
||||
totalPages: reviews.length ? 1 : 0,
|
||||
ratingSummary: {
|
||||
averageScore: reviews.length ? 8 : null,
|
||||
ratingCount: reviews.length,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((yes, no) => {
|
||||
resolve = yes;
|
||||
reject = no;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
function Harness() {
|
||||
const state = useGameReviews('game-1', true);
|
||||
return <GameReviews state={state} canReview onLogin={mocks.login} />;
|
||||
}
|
||||
async function form() {
|
||||
return screen.findByRole('textbox', { name: '评论(可选)' });
|
||||
}
|
||||
function score(value: number) {
|
||||
fireEvent.click(screen.getByRole('radio', { name: `${value} 分` }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.userId = 'user-1';
|
||||
mocks.list.mockResolvedValue(list());
|
||||
mocks.mine.mockResolvedValue({ review: null });
|
||||
mocks.save.mockResolvedValue({
|
||||
review: review(),
|
||||
ratingSummary: { averageScore: 8, ratingCount: 1 },
|
||||
});
|
||||
mocks.game.mockImplementation(async (id: string) => ({
|
||||
id,
|
||||
title: '测试游戏',
|
||||
summary: '简介',
|
||||
description: '介绍',
|
||||
category: '益智',
|
||||
tags: [],
|
||||
coverColor: '#abc',
|
||||
icon: '✦',
|
||||
author: { id: 'author', name: '作者' },
|
||||
deviceSupport: { mobile: true, desktop: true, touch: true },
|
||||
status: 'published',
|
||||
currentVersion: {
|
||||
id: 'v1',
|
||||
version: '1',
|
||||
entryUrl: 'https://play.example.test/releases/v1/index.html',
|
||||
controls: [],
|
||||
},
|
||||
playCount: 0,
|
||||
}));
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('游戏评价', () => {
|
||||
it('隐藏评价本人可见且编辑保持提示,保存后仍不参与公共统计', async () => {
|
||||
mocks.mine.mockResolvedValue({ review: review({ isHidden: true }) });
|
||||
mocks.save.mockResolvedValue({
|
||||
review: review({ isHidden: true, score: 9, comment: '修改后的评论' }),
|
||||
ratingSummary: { averageScore: null, ratingCount: 0 },
|
||||
});
|
||||
render(<Harness />);
|
||||
expect(await screen.findByText('已被管理员隐藏')).toBeTruthy();
|
||||
expect(screen.getByText('暂无评分')).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑评价' }));
|
||||
expect(screen.getByText('已被管理员隐藏')).toBeTruthy();
|
||||
score(9);
|
||||
fireEvent.change(await form(), { target: { value: '修改后的评论' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交' }));
|
||||
await screen.findByRole('button', { name: '编辑评价' });
|
||||
expect(mocks.save).toHaveBeenCalledWith('game-1', {
|
||||
score: 9,
|
||||
comment: '修改后的评论',
|
||||
});
|
||||
expect(screen.getByText('已被管理员隐藏')).toBeTruthy();
|
||||
expect(screen.getByText('暂无评分')).toBeTruthy();
|
||||
expect(screen.getByText('暂无评价')).toBeTruthy();
|
||||
});
|
||||
it('必选评分、首次取消清空草稿且不写入,允许空评论提交', async () => {
|
||||
render(<Harness />);
|
||||
const input = await form();
|
||||
expect(
|
||||
(screen.getByRole('button', { name: '提交' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
score(1);
|
||||
fireEvent.change(input, { target: { value: '草稿' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }));
|
||||
expect((input as HTMLTextAreaElement).value).toBe('');
|
||||
expect(screen.getByText('0/4000')).toBeTruthy();
|
||||
expect(mocks.save).not.toHaveBeenCalled();
|
||||
score(10);
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交' }));
|
||||
await waitFor(() =>
|
||||
expect(mocks.save).toHaveBeenCalledWith('game-1', {
|
||||
score: 10,
|
||||
comment: '',
|
||||
}),
|
||||
);
|
||||
await screen.findByRole('button', { name: '编辑评价' });
|
||||
expect(screen.queryByRole('textbox')).toBeNull();
|
||||
});
|
||||
|
||||
it('自己的评价同时出现在个人区与公共列表,编辑预填、取消恢复、保存更新', async () => {
|
||||
mocks.mine.mockResolvedValue({ review: review() });
|
||||
mocks.list.mockResolvedValue(list([review()]));
|
||||
render(<Harness />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '编辑评价' }));
|
||||
const input = await form();
|
||||
expect((input as HTMLTextAreaElement).value).toBe('原评论');
|
||||
expect(
|
||||
(screen.getByRole('radio', { name: '8 分' }) as HTMLInputElement).checked,
|
||||
).toBe(true);
|
||||
fireEvent.change(input, { target: { value: '未保存' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }));
|
||||
expect(screen.getAllByText('原评论')).toHaveLength(2);
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑评价' }));
|
||||
fireEvent.change(await form(), { target: { value: '新评论' } });
|
||||
score(9);
|
||||
const updated = review({ score: 9, comment: '新评论' });
|
||||
mocks.save.mockResolvedValue({
|
||||
review: updated,
|
||||
ratingSummary: { averageScore: 9, ratingCount: 1 },
|
||||
});
|
||||
mocks.list.mockResolvedValue(
|
||||
list([updated], { ratingSummary: { averageScore: 9, ratingCount: 1 } }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交' }));
|
||||
await screen.findByRole('button', { name: '编辑评价' });
|
||||
expect(mocks.save).toHaveBeenCalledWith('game-1', {
|
||||
score: 9,
|
||||
comment: '新评论',
|
||||
});
|
||||
expect(screen.getByText('9.0/10 · 1 人评分')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('按 Unicode 码点计数和归一化换行,4000 可提交,4001 粘贴/输入法输入阻止提交', async () => {
|
||||
expect(reviewCommentLength('中a😀\r\n \r')).toBe(6);
|
||||
render(<Harness />);
|
||||
const input = await form();
|
||||
score(1);
|
||||
fireEvent.compositionStart(input);
|
||||
fireEvent.change(input, { target: { value: '😀'.repeat(4001) } });
|
||||
fireEvent.compositionEnd(input);
|
||||
expect(screen.getByText('4001/4000')).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByRole('button', { name: '提交' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
expect(mocks.save).not.toHaveBeenCalled();
|
||||
fireEvent.change(input, { target: { value: '😀'.repeat(4000) } });
|
||||
expect(screen.getByText('4000/4000')).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交' }));
|
||||
await waitFor(() =>
|
||||
expect(mocks.save).toHaveBeenCalledWith('game-1', {
|
||||
score: 1,
|
||||
comment: '😀'.repeat(4000),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('提交期间冻结输入与按钮,失败恢复编辑并保留草稿', async () => {
|
||||
const pending = deferred<never>();
|
||||
mocks.save.mockReturnValue(pending.promise);
|
||||
render(<Harness />);
|
||||
const input = await form();
|
||||
score(7);
|
||||
fireEvent.change(input, { target: { value: '保留草稿' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交' }));
|
||||
expect((input as HTMLTextAreaElement).disabled).toBe(true);
|
||||
expect(
|
||||
screen.getByRole('radio', { name: '7 分' }).closest('fieldset')?.disabled,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(screen.getByRole('button', { name: '取消' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(screen.getByRole('button', { name: '提交中…' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
await act(async () => pending.reject(new Error('保存失败')));
|
||||
expect(screen.getByText('保存失败')).toBeTruthy();
|
||||
expect((input as HTMLTextAreaElement).value).toBe('保留草稿');
|
||||
expect((input as HTMLTextAreaElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('个人读取失败不展示首次表单,摘要失败不显示暂无评分,可分别重试', async () => {
|
||||
mocks.mine.mockRejectedValueOnce(new Error('个人失败'));
|
||||
mocks.list.mockRejectedValueOnce(new Error('列表失败'));
|
||||
render(<Harness />);
|
||||
await screen.findByText('个人失败');
|
||||
expect(screen.queryByRole('textbox')).toBeNull();
|
||||
expect(screen.queryByText('暂无评分')).toBeNull();
|
||||
const personal = document.querySelector(
|
||||
'.game-reviews__personal',
|
||||
) as HTMLElement;
|
||||
fireEvent.click(within(personal).getByRole('button', { name: '重试' }));
|
||||
await form();
|
||||
fireEvent.click(
|
||||
within(
|
||||
document.querySelector('.game-reviews__public') as HTMLElement,
|
||||
).getAllByRole('button', { name: '重试' })[0]!,
|
||||
);
|
||||
await screen.findByText('暂无评分');
|
||||
});
|
||||
|
||||
it('服务端分页保留总数,修改保留当前页、首次提交回第一页', async () => {
|
||||
mocks.mine.mockResolvedValue({ review: review() });
|
||||
mocks.list.mockImplementation(async (_id, page) =>
|
||||
list([review({ comment: `第${page}页` })], {
|
||||
page,
|
||||
total: 21,
|
||||
totalPages: 2,
|
||||
ratingSummary: { averageScore: 8.2, ratingCount: 21 },
|
||||
}),
|
||||
);
|
||||
render(<Harness />);
|
||||
await screen.findByText('第1页');
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||
await screen.findByText('第2页');
|
||||
expect(screen.getByText('8.2/10 · 21 人评分')).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑评价' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交' }));
|
||||
await waitFor(() => expect(mocks.list).toHaveBeenCalledTimes(3));
|
||||
expect(mocks.list.mock.calls[2]).toEqual(['game-1', 2]);
|
||||
cleanup();
|
||||
mocks.mine.mockResolvedValue({ review: null });
|
||||
mocks.list.mockClear();
|
||||
render(<Harness />);
|
||||
await form();
|
||||
await screen.findByText('第1页');
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||
await screen.findByText('第2页');
|
||||
score(8);
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交' }));
|
||||
await waitFor(() => expect(mocks.list).toHaveBeenCalledTimes(3));
|
||||
expect(mocks.list.mock.calls[2]).toEqual(['game-1', 1]);
|
||||
});
|
||||
|
||||
it('保存后迟到的初始列表不能覆盖新摘要或结束刷新状态', async () => {
|
||||
const oldList = deferred<GameDistributionReviewsResponse>();
|
||||
const refreshed = deferred<GameDistributionReviewsResponse>();
|
||||
const saving = deferred<{
|
||||
review: GameDistributionReview;
|
||||
ratingSummary: { averageScore: number; ratingCount: number };
|
||||
}>();
|
||||
mocks.mine.mockResolvedValueOnce({ review: review() });
|
||||
mocks.list
|
||||
.mockReturnValueOnce(oldList.promise)
|
||||
.mockReturnValueOnce(refreshed.promise);
|
||||
mocks.save.mockReturnValueOnce(saving.promise);
|
||||
render(<Harness />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '编辑评价' }));
|
||||
fireEvent.change(await form(), { target: { value: '已保存' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交' }));
|
||||
const updated = review({ comment: '已保存' });
|
||||
await act(async () =>
|
||||
saving.resolve({
|
||||
review: updated,
|
||||
ratingSummary: { averageScore: 9, ratingCount: 1 },
|
||||
}),
|
||||
);
|
||||
await screen.findByText('已保存');
|
||||
await act(async () => {
|
||||
oldList.resolve(list());
|
||||
});
|
||||
expect(screen.getByText('已保存')).toBeTruthy();
|
||||
expect(screen.getByText('9.0/10 · 1 人评分')).toBeTruthy();
|
||||
expect(screen.getByText('正在加载评价…')).toBeTruthy();
|
||||
await act(async () => refreshed.resolve(list([updated])));
|
||||
expect(screen.queryByText('正在加载评价…')).toBeNull();
|
||||
expect(screen.getAllByText('已保存')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('快速翻页只接受最新请求,旧失败和加载结束不覆盖当前页', async () => {
|
||||
const second = deferred<GameDistributionReviewsResponse>();
|
||||
const third = deferred<GameDistributionReviewsResponse>();
|
||||
mocks.list
|
||||
.mockResolvedValueOnce(list([review()], { total: 41, totalPages: 3 }))
|
||||
.mockReturnValueOnce(second.promise)
|
||||
.mockReturnValueOnce(third.promise);
|
||||
render(<Harness />);
|
||||
await screen.findByText('原评论');
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||
await act(async () => second.reject(new Error('旧页错误')));
|
||||
expect(screen.getByText('正在加载评价…')).toBeTruthy();
|
||||
expect(screen.queryByText('旧页错误')).toBeNull();
|
||||
await act(async () =>
|
||||
third.resolve(
|
||||
list([review({ comment: '最新页' })], {
|
||||
page: 3,
|
||||
total: 41,
|
||||
totalPages: 3,
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(screen.getByText('最新页')).toBeTruthy();
|
||||
expect(screen.getByText('3 / 3 页')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('游客能浏览评分和空评论评价,登录入口复用平台登录', async () => {
|
||||
mocks.userId = null;
|
||||
mocks.list.mockResolvedValue(list([review({ comment: '' })]));
|
||||
render(
|
||||
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '登录后评价' }));
|
||||
expect(mocks.login).toHaveBeenCalled();
|
||||
expect(mocks.mine).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole('textbox')).toBeNull();
|
||||
expect(await screen.findByText('8/10')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('切换账号清空草稿,切换游戏或退出时隔离旧个人响应', async () => {
|
||||
const first = deferred<{ review: GameDistributionReview | null }>();
|
||||
mocks.mine
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockResolvedValue({ review: null });
|
||||
const { rerender } = render(
|
||||
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
|
||||
);
|
||||
await screen.findByText('正在加载我的评价…');
|
||||
mocks.userId = 'user-2';
|
||||
rerender(
|
||||
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
|
||||
);
|
||||
const input = await form();
|
||||
fireEvent.change(input, { target: { value: '账号二草稿' } });
|
||||
await act(async () =>
|
||||
first.resolve({ review: review({ comment: '账号一私有评论' }) }),
|
||||
);
|
||||
expect(screen.queryByText('账号一私有评论')).toBeNull();
|
||||
rerender(
|
||||
<GameDetailPage gameId="game-2" onBack={vi.fn()} onPlay={vi.fn()} />,
|
||||
);
|
||||
expect(((await form()) as HTMLTextAreaElement).value).toBe('');
|
||||
mocks.userId = null;
|
||||
rerender(
|
||||
<GameDetailPage gameId="game-2" onBack={vi.fn()} onPlay={vi.fn()} />,
|
||||
);
|
||||
await screen.findByRole('button', { name: '登录后评价' });
|
||||
expect(screen.queryByRole('textbox')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
import './gameReviews.css';
|
||||
|
||||
import { GameRatingSummaryText } from '@genarrative/shared/components';
|
||||
|
||||
import type { GameDistributionReview } from '../../services/gameDistributionClient';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import {
|
||||
GAME_REVIEW_COMMENT_LIMIT,
|
||||
normalizeReviewNewlines,
|
||||
reviewCommentLength,
|
||||
type useGameReviews,
|
||||
} from './useGameReviews';
|
||||
|
||||
type ReviewsState = ReturnType<typeof useGameReviews>;
|
||||
|
||||
export function GameRatingSummary({ state }: { state: ReviewsState }) {
|
||||
if (state.summary) {
|
||||
return <GameRatingSummaryText summary={state.summary} />;
|
||||
}
|
||||
if (state.listError) {
|
||||
return (
|
||||
<span role="status">
|
||||
评分加载失败{' '}
|
||||
<button type="button" onClick={() => void state.loadPage(state.page)}>
|
||||
重试
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span role="status">评分加载中…</span>;
|
||||
}
|
||||
|
||||
function ReviewCard({
|
||||
review,
|
||||
personal = false,
|
||||
}: {
|
||||
review: GameDistributionReview;
|
||||
personal?: boolean;
|
||||
}) {
|
||||
const changed = review.updatedAt !== review.createdAt;
|
||||
return (
|
||||
<article className="game-review-card">
|
||||
<div className="game-review-card__header">
|
||||
<span className="game-avatar">
|
||||
{review.author.avatarUrl ? (
|
||||
<img src={review.author.avatarUrl} alt="" loading="lazy" />
|
||||
) : (
|
||||
review.author.name.slice(0, 1)
|
||||
)}
|
||||
</span>
|
||||
<strong>{review.author.name}</strong>
|
||||
<span className="game-review-score">{review.score}/10</span>
|
||||
</div>
|
||||
{review.comment ? (
|
||||
<p className="game-review-comment">{review.comment}</p>
|
||||
) : null}
|
||||
<small>
|
||||
{personal || changed ? '修改时间:' : '提交时间:'}
|
||||
<time
|
||||
dateTime={personal || changed ? review.updatedAt : review.createdAt}
|
||||
>
|
||||
{new Date(
|
||||
personal || changed ? review.updatedAt : review.createdAt,
|
||||
).toLocaleString()}
|
||||
</time>
|
||||
{!personal && changed ? (
|
||||
<span>
|
||||
{' '}
|
||||
· 首次提交:
|
||||
<time dateTime={review.createdAt}>
|
||||
{new Date(review.createdAt).toLocaleString()}
|
||||
</time>
|
||||
</span>
|
||||
) : null}
|
||||
</small>
|
||||
{personal && review.isHidden ? (
|
||||
<PlatformStatusMessage tone="info" surface="platform">
|
||||
已被管理员隐藏
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewError({
|
||||
message,
|
||||
retry,
|
||||
}: {
|
||||
message: string;
|
||||
retry?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<PlatformStatusMessage tone="error" surface="platform">
|
||||
<span>{message}</span>
|
||||
{retry ? (
|
||||
<button type="button" onClick={retry}>
|
||||
重试
|
||||
</button>
|
||||
) : null}
|
||||
</PlatformStatusMessage>
|
||||
);
|
||||
}
|
||||
|
||||
export function GameReviews({
|
||||
state,
|
||||
canReview,
|
||||
onLogin,
|
||||
}: {
|
||||
state: ReviewsState;
|
||||
canReview: boolean;
|
||||
onLogin: () => void;
|
||||
}) {
|
||||
const length = reviewCommentLength(state.comment);
|
||||
const tooLong = length > GAME_REVIEW_COMMENT_LIMIT;
|
||||
const totalPages = state.list?.totalPages ?? 0;
|
||||
return (
|
||||
<section className="game-reviews" aria-label="游戏评价">
|
||||
<div className="game-reviews__personal">
|
||||
<h2>我的评价</h2>
|
||||
{!canReview ? (
|
||||
<PlatformActionButton tone="secondary" onClick={onLogin}>
|
||||
登录后评价
|
||||
</PlatformActionButton>
|
||||
) : state.myLoading ? (
|
||||
<p role="status">正在加载我的评价…</p>
|
||||
) : state.myError ? (
|
||||
<ReviewError
|
||||
message={state.myError}
|
||||
retry={() => void state.loadMine()}
|
||||
/>
|
||||
) : state.myReview && !state.editing ? (
|
||||
<>
|
||||
<ReviewCard review={state.myReview} personal />
|
||||
<PlatformActionButton tone="secondary" onClick={state.edit}>
|
||||
编辑评价
|
||||
</PlatformActionButton>
|
||||
</>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void state.submit();
|
||||
}}
|
||||
>
|
||||
{state.myReview?.isHidden ? (
|
||||
<PlatformStatusMessage tone="info" surface="platform">
|
||||
已被管理员隐藏
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
<fieldset disabled={state.saving} className="game-review-rating">
|
||||
<legend>评分(必选)</legend>
|
||||
<div className="game-review-rating__options">
|
||||
{Array.from({ length: 10 }, (_, index) => index + 1).map(
|
||||
(value) => (
|
||||
<label key={value}>
|
||||
<input
|
||||
type="radio"
|
||||
name="game-review-score"
|
||||
value={value}
|
||||
checked={state.score === value}
|
||||
onChange={() => state.setScore(value)}
|
||||
aria-label={`${value} 分`}
|
||||
/>
|
||||
<span>{value}</span>
|
||||
</label>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="game-review-comment-label">
|
||||
<label htmlFor="game-review-comment">评论(可选)</label>
|
||||
<span id="game-review-count" aria-live="polite">
|
||||
{length}/4000
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
id="game-review-comment"
|
||||
rows={5}
|
||||
value={state.comment}
|
||||
disabled={state.saving}
|
||||
onChange={(event) =>
|
||||
state.setComment(normalizeReviewNewlines(event.target.value))
|
||||
}
|
||||
aria-describedby={
|
||||
tooLong
|
||||
? 'game-review-count game-review-limit-error'
|
||||
: 'game-review-count'
|
||||
}
|
||||
aria-invalid={tooLong}
|
||||
/>
|
||||
{tooLong ? (
|
||||
<p id="game-review-limit-error" role="alert">
|
||||
评论不能超过 4000 个字符
|
||||
</p>
|
||||
) : null}
|
||||
{state.saveError ? <ReviewError message={state.saveError} /> : null}
|
||||
<div className="game-review-actions">
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
type="button"
|
||||
disabled={state.saving}
|
||||
onClick={state.cancel}
|
||||
>
|
||||
取消
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
type="submit"
|
||||
disabled={state.saving || tooLong || state.score === null}
|
||||
>
|
||||
{state.saving ? '提交中…' : '提交'}
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
<div className="game-reviews__public">
|
||||
<div className="game-reviews__heading">
|
||||
<h2>全部评价</h2>
|
||||
<GameRatingSummary state={state} />
|
||||
</div>
|
||||
{state.listLoading ? (
|
||||
<p role="status">正在加载评价…</p>
|
||||
) : state.listError ? (
|
||||
<ReviewError
|
||||
message={state.listError}
|
||||
retry={() => void state.loadPage(state.page)}
|
||||
/>
|
||||
) : state.list?.reviews.length ? (
|
||||
<div className="game-review-list">
|
||||
{state.list.reviews.map((review) => (
|
||||
<ReviewCard key={review.id} review={review} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<PlatformEmptyState>暂无评价</PlatformEmptyState>
|
||||
)}
|
||||
<nav className="game-review-pagination" aria-label="评价分页">
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
disabled={state.page <= 1}
|
||||
onClick={() => void state.loadPage(state.page - 1)}
|
||||
>
|
||||
上一页
|
||||
</PlatformActionButton>
|
||||
<span>
|
||||
{totalPages === 0 ? '0 / 0 页' : `${state.page} / ${totalPages} 页`}
|
||||
</span>
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
disabled={state.page >= totalPages}
|
||||
onClick={() => void state.loadPage(state.page + 1)}
|
||||
>
|
||||
下一页
|
||||
</PlatformActionButton>
|
||||
</nav>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -238,6 +238,14 @@
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.game-card__rating {
|
||||
display: block;
|
||||
margin-top: 0.42rem;
|
||||
color: var(--platform-text-base);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.game-card__meta {
|
||||
display: flex;
|
||||
gap: 0.38rem;
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
.game-reviews {
|
||||
display: grid;
|
||||
gap: 28px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
.game-reviews h2 {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.game-reviews__personal,
|
||||
.game-reviews__public {
|
||||
min-width: 0;
|
||||
}
|
||||
.game-reviews__heading {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
}
|
||||
.game-review-card {
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid var(--platform-surface-border);
|
||||
}
|
||||
.game-review-card__header {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.game-review-card .game-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: inherit;
|
||||
}
|
||||
.game-review-score {
|
||||
margin-left: auto;
|
||||
font-weight: 600;
|
||||
}
|
||||
.game-review-comment {
|
||||
margin-top: 8px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.game-review-card small {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.game-review-rating {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.game-review-rating legend {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.game-review-rating__options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.game-review-rating__options label {
|
||||
position: relative;
|
||||
}
|
||||
.game-review-rating__options input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.game-review-rating__options span {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
border: 1px solid var(--platform-surface-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.game-review-rating__options input:checked + span {
|
||||
background: var(--platform-button-primary-fill);
|
||||
color: var(--platform-button-primary-text);
|
||||
}
|
||||
.game-review-rating__options input:focus-visible + span {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.game-review-rating:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.game-review-comment-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.game-reviews textarea {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
padding: 12px;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border: 1px solid var(--platform-surface-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.game-reviews textarea[aria-invalid='true'] {
|
||||
border-color: #ba3325;
|
||||
}
|
||||
.game-review-actions,
|
||||
.game-review-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.game-review-pagination {
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.game-reviews__personal > button {
|
||||
margin-top: 12px;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
type GameDistributionRatingSummary,
|
||||
type GameDistributionReview,
|
||||
type GameDistributionReviewsResponse,
|
||||
getMyGameReview,
|
||||
listGameReviews,
|
||||
saveMyGameReview,
|
||||
} from '../../services/gameDistributionClient';
|
||||
|
||||
export const GAME_REVIEW_COMMENT_LIMIT = 4000;
|
||||
|
||||
export function normalizeReviewNewlines(comment: string) {
|
||||
return comment.replace(/\r\n?/g, '\n');
|
||||
}
|
||||
|
||||
export function reviewCommentLength(comment: string) {
|
||||
return Array.from(normalizeReviewNewlines(comment)).length;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
/** 外层以游戏和账号身份为 key,切换上下文会卸载并清空整个评价草稿。 */
|
||||
export function useGameReviews(gameId: string, canReview: boolean) {
|
||||
const [summary, setSummary] = useState<GameDistributionRatingSummary | null>(
|
||||
null,
|
||||
);
|
||||
const [list, setList] = useState<GameDistributionReviewsResponse | null>(
|
||||
null,
|
||||
);
|
||||
const [page, setPage] = useState(1);
|
||||
const [listLoading, setListLoading] = useState(true);
|
||||
const [listError, setListError] = useState('');
|
||||
const [myReview, setMyReview] = useState<GameDistributionReview | null>(null);
|
||||
const [myLoading, setMyLoading] = useState(canReview);
|
||||
const [myError, setMyError] = useState('');
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [score, setScore] = useState<number | null>(null);
|
||||
const [comment, setComment] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState('');
|
||||
const listSerial = useRef(0);
|
||||
const mySerial = useRef(0);
|
||||
const alive = useRef(true);
|
||||
const savingRef = useRef(false);
|
||||
const pageRef = useRef(1);
|
||||
|
||||
const loadPage = useCallback(
|
||||
async (nextPage: number) => {
|
||||
const serial = ++listSerial.current;
|
||||
pageRef.current = nextPage;
|
||||
setPage(nextPage);
|
||||
setListLoading(true);
|
||||
setListError('');
|
||||
try {
|
||||
const response = await listGameReviews(gameId, nextPage);
|
||||
if (!alive.current || serial !== listSerial.current) return;
|
||||
setList(response);
|
||||
setSummary(response.ratingSummary);
|
||||
} catch (error) {
|
||||
if (!alive.current || serial !== listSerial.current) return;
|
||||
setListError(errorMessage(error, '读取游戏评价失败'));
|
||||
} finally {
|
||||
if (alive.current && serial === listSerial.current)
|
||||
setListLoading(false);
|
||||
}
|
||||
},
|
||||
[gameId],
|
||||
);
|
||||
|
||||
const loadMine = useCallback(async () => {
|
||||
if (!canReview) return;
|
||||
const serial = ++mySerial.current;
|
||||
setMyLoading(true);
|
||||
setMyError('');
|
||||
try {
|
||||
const response = await getMyGameReview(gameId);
|
||||
if (!alive.current || serial !== mySerial.current) return;
|
||||
setMyReview(response.review);
|
||||
} catch (error) {
|
||||
if (!alive.current || serial !== mySerial.current) return;
|
||||
setMyError(errorMessage(error, '读取我的评价失败'));
|
||||
} finally {
|
||||
if (alive.current && serial === mySerial.current) setMyLoading(false);
|
||||
}
|
||||
}, [canReview, gameId]);
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
void loadPage(1);
|
||||
void loadMine();
|
||||
return () => {
|
||||
alive.current = false;
|
||||
listSerial.current += 1;
|
||||
mySerial.current += 1;
|
||||
};
|
||||
}, [loadMine, loadPage]);
|
||||
|
||||
function edit() {
|
||||
if (!myReview || savingRef.current) return;
|
||||
setScore(myReview.score);
|
||||
setComment(myReview.comment);
|
||||
setSaveError('');
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (savingRef.current) return;
|
||||
setScore(null);
|
||||
setComment('');
|
||||
setSaveError('');
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (savingRef.current || !canReview || myLoading || myError) return;
|
||||
const normalizedComment = normalizeReviewNewlines(comment);
|
||||
if (score === null || !Number.isInteger(score) || score < 1 || score > 10) {
|
||||
setSaveError('请选择 1–10 分');
|
||||
return;
|
||||
}
|
||||
if (reviewCommentLength(normalizedComment) > GAME_REVIEW_COMMENT_LIMIT) {
|
||||
setSaveError('评论不能超过 4000 个字符');
|
||||
return;
|
||||
}
|
||||
const wasNew = myReview === null;
|
||||
savingRef.current = true;
|
||||
setSaving(true);
|
||||
setSaveError('');
|
||||
try {
|
||||
const response = await saveMyGameReview(gameId, {
|
||||
score,
|
||||
comment: normalizedComment,
|
||||
});
|
||||
if (!alive.current) return;
|
||||
// 保存成功后,先隔离所有旧读取,再使用权威记录与摘要。
|
||||
mySerial.current += 1;
|
||||
listSerial.current += 1;
|
||||
setMyLoading(false);
|
||||
setMyError('');
|
||||
setMyReview(response.review);
|
||||
setSummary(response.ratingSummary);
|
||||
setEditing(false);
|
||||
setScore(null);
|
||||
setComment('');
|
||||
void loadPage(wasNew ? 1 : pageRef.current);
|
||||
} catch (error) {
|
||||
if (alive.current) setSaveError(errorMessage(error, '保存评价失败'));
|
||||
} finally {
|
||||
savingRef.current = false;
|
||||
if (alive.current) setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
summary,
|
||||
list,
|
||||
page,
|
||||
listLoading,
|
||||
listError,
|
||||
loadPage,
|
||||
myReview,
|
||||
myLoading,
|
||||
myError,
|
||||
loadMine,
|
||||
editing,
|
||||
edit,
|
||||
cancel,
|
||||
score,
|
||||
setScore,
|
||||
comment,
|
||||
setComment,
|
||||
saving,
|
||||
saveError,
|
||||
submit,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user