实现网站游戏评分与评价 (#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

公开游戏详情页新增评分与评价:登录用户可提交并修改自己对每款游戏的唯一一条 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:
2026-10-01 19:19:58 +08:00
committed by 孔令弘
parent 44f415d057
commit b60aea4e71
81 changed files with 8594 additions and 60 deletions
@@ -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,
};
}