作品详情页在非公开状态下不再请求评价接口,封面显示审核状态
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m2s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 4m16s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 4m49s
Project CI / Backend tests (pull_request) Successful in 7m16s
Project CI / Frontend tests (pull_request) Successful in 3m7s
Project CI / Native shell tests (pull_request) Successful in 6m59s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m40s
Project CI / Repository checks (pull_request) Successful in 5m42s

- useGameReviews 新增第 3 参 publiclyVisible:非公开(game.status !== 'published',含审核中 / 审核未通过 / 未公开 / 已下架)时 loadPage / loadMine / submit 直接 return,不置错误态
- GameReviews 两栏在非公开时渲染既有 PlatformEmptyState「暂无评价」,不出现错误块 / 重试 / 评分失败
- 封面占位文案新增 resolveNonPublicCoverLabel(pending_review → 审核中、rejected → 审核未通过,其余退回作品级文案),样式 .game-detail-cover__pending 落在本域 CSS
- hero 评分与分页在非公开时不渲染
- 已公开(published)行为保持不变
- 新增行为测试:非公开时零请求 + 两栏空态 + 封面文案映射;已公开仍照常发请求
This commit is contained in:
2026-10-04 12:34:42 +08:00
parent ddc3832279
commit aad8eb2aba
7 changed files with 294 additions and 55 deletions
@@ -34,6 +34,7 @@ import { GameRatingSummary, GameReviews } from './GameReviews';
import { GameVersionHistory } from './GameVersionHistory';
import {
GAME_STATUS_LABEL,
resolveNonPublicCoverLabel,
VERSION_STATUS_LABEL,
VERSION_STATUS_NEEDS_AUTHOR_ACTION,
} from './gameVersionHistoryModel';
@@ -285,7 +286,12 @@ function GameDetailContent({
onBack: () => void;
onPlay: (gameId: string) => void;
}) {
const reviews = useGameReviews(game.id, canReview);
/**
* 只有已公开作品才有公开评价/评分投影;审核中、审核未通过、未公开、已下架
* 一律不请求评价接口,封面位改为状态文案。
*/
const publiclyVisible = game.status === 'published';
const reviews = useGameReviews(game.id, canReview, publiclyVisible);
/** 作者视角的条目没有公开投影 `currentVersion`,可玩版本取版本历史里的已发布版本。 */
const publishedVersion =
ownerGame?.versions.find((item) => item.status === 'published') ?? null;
@@ -296,6 +302,9 @@ function GameDetailContent({
: ''
: (game.currentVersion?.version ?? '');
const latestVersion = ownerView ? (ownerGame?.latestVersion ?? null) : null;
const coverPlaceholderLabel = publiclyVisible
? ''
: resolveNonPublicCoverLabel(game.status, latestVersion?.status);
const latestReviewReason = latestVersion?.reviewReason?.trim() ?? '';
const mobilePlaybackBlocked = isMobileViewport && !game.deviceSupport.mobile;
const playbackBlocked = !version || mobilePlaybackBlocked;
@@ -310,10 +319,18 @@ function GameDetailContent({
screenshotObjectKeys={game.screenshots}
coverColor={game.coverColor}
icon={game.icon}
renderAsset={(asset) => <GameDetailResolvedAsset {...asset} />}
renderAsset={(asset) =>
asset.role === 'cover' && coverPlaceholderLabel ? (
<span className="game-detail-cover__pending">
{coverPlaceholderLabel}
</span>
) : (
<GameDetailResolvedAsset {...asset} />
)
}
controlsLabel={resolveControlsLabel(version, game.inputModes)}
versionLabel={versionLabel}
rating={<GameRatingSummary state={reviews} />}
rating={publiclyVisible ? <GameRatingSummary state={reviews} /> : null}
actions={
<>
<PlatformActionButton
@@ -381,7 +398,12 @@ function GameDetailContent({
</section>
) : null}
</GameDetailDisplay>
<GameReviews state={reviews} canReview={canReview} onLogin={onLogin} />
<GameReviews
state={reviews}
canReview={canReview}
onLogin={onLogin}
publiclyVisible={publiclyVisible}
/>
<p className="game-detail-footnote">
<Maximize2 aria-hidden="true" />{' '}
游玩时可以切换全屏,游戏运行在独立的安全容器中。
@@ -11,6 +11,7 @@ import {
} from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { GameDistributionVersionStatus } from '../../../packages/shared/src/contracts/gameDistribution';
import type {
GameDistributionGame,
GameDistributionMyGame,
@@ -19,25 +20,31 @@ import type {
import { GameDetailPage } from './GameDetailPage';
import { GameGalleryPage } from './GameGalleryPage';
import { GAME_PLAY_STARTUP_TIMEOUT_MS, GamePlayPage } from './GamePlayPage';
import { resolveNonPublicCoverLabel } from './gameVersionHistoryModel';
const listGamesMock = vi.hoisted(() => vi.fn());
const getGameMock = vi.hoisted(() => vi.fn());
const getMyGameMock = vi.hoisted(() => vi.fn());
const loadVersionRecoveryActionsMock = vi.hoisted(() => vi.fn());
const resolvedAssetUrlMock = vi.hoisted(() => vi.fn());
const listGameReviewsMock = vi.hoisted(() => vi.fn());
const getMyGameReviewMock = vi.hoisted(() => vi.fn());
vi.mock('../../services/gameDistributionClient', () => ({
listGames: listGamesMock,
getGame: getGameMock,
getMyGame: getMyGameMock,
loadVersionRecoveryActions: loadVersionRecoveryActionsMock,
listGameReviews: vi.fn().mockResolvedValue({
reviews: [],
page: 1,
pageSize: 20,
total: 0,
totalPages: 0,
ratingSummary: { averageScore: null, ratingCount: 0 },
listGameReviews: listGameReviewsMock,
getMyGameReview: getMyGameReviewMock,
}));
// 作者视角详情页要按登录身份渲染评价区,这里固定一个可访问受保护数据的用户。
vi.mock('../auth/AuthUiContext', () => ({
useAuthUi: () => ({
user: { id: 'author-1' },
canAccessProtectedData: true,
openLoginModal: vi.fn(),
}),
}));
@@ -124,6 +131,15 @@ beforeEach(() => {
getGameMock.mockResolvedValue(createGame());
getMyGameMock.mockResolvedValue(null);
loadVersionRecoveryActionsMock.mockResolvedValue({});
listGameReviewsMock.mockResolvedValue({
reviews: [],
page: 1,
pageSize: 20,
total: 0,
totalPages: 0,
ratingSummary: { averageScore: null, ratingCount: 0 },
});
getMyGameReviewMock.mockResolvedValue({ review: null });
resolvedAssetUrlMock.mockImplementation((objectKey: string) =>
objectKey ? `https://assets.example.test/${objectKey}` : '',
);
@@ -293,6 +309,99 @@ describe('GameDetailPage 作者视角', () => {
expect(screen.getByText('作品尚未公开,暂时没有在线可玩版本')).toBeTruthy();
});
it('未公开作品的封面显示审核中,且不请求评价与评分接口', async () => {
getMyGameMock.mockResolvedValue(createOwnerGame());
render(
<GameDetailPage
gameId="game-1"
onBack={vi.fn()}
onPlay={vi.fn()}
ownerView
/>,
);
await screen.findByText('作品状态');
// 封面位按最新版本状态给出「审核中」,不再只剩手柄占位图。
const cover = document.querySelector('.game-detail-cover') as HTMLElement;
expect(within(cover).getByText('审核中')).toBeTruthy();
// 两栏评价都是空态:没有错误块、没有重试、没有评分加载失败。
expect(screen.getAllByText('暂无评价')).toHaveLength(2);
expect(screen.queryByText('评分加载失败')).toBeNull();
expect(screen.queryByText('评分加载中…')).toBeNull();
expect(screen.queryByRole('button', { name: '重试' })).toBeNull();
expect(screen.queryByRole('textbox')).toBeNull();
// 代码级门禁:非公开作品一个评价/评分请求都不发。
expect(listGameReviewsMock).not.toHaveBeenCalled();
expect(getMyGameReviewMock).not.toHaveBeenCalled();
});
it('还没有提交过版本的非公开作品封面显示未公开', async () => {
getMyGameMock.mockResolvedValue(
createOwnerGame({ latestVersion: null, versions: [] }),
);
render(
<GameDetailPage
gameId="game-1"
onBack={vi.fn()}
onPlay={vi.fn()}
ownerView
/>,
);
await screen.findByText('还没有提交过版本');
expect(
within(
document.querySelector('.game-detail-cover') as HTMLElement,
).getByText('未公开'),
).toBeTruthy();
expect(screen.queryByText('评分加载失败')).toBeNull();
expect(listGameReviewsMock).not.toHaveBeenCalled();
expect(getMyGameReviewMock).not.toHaveBeenCalled();
});
it.each<[string, GameDistributionVersionStatus | null, string]>([
['unpublished', null, '未公开'],
['unpublished', 'awaiting_upload', '未公开'],
['unpublished', 'pending_review', '审核中'],
['unpublished', 'rejected', '审核未通过'],
['suspended', 'pending_review', '审核中'],
['suspended', null, '已被封禁'],
])('封面文案 %s + %s -> %s', (gameStatus, versionStatus, expected) => {
expect(resolveNonPublicCoverLabel(gameStatus, versionStatus)).toBe(
expected,
);
});
it('审核未通过的作品封面用更准确的驳回文案', async () => {
const rejected: GameDistributionVersionStatusEntry = {
...reviewVersion,
status: 'rejected',
};
getMyGameMock.mockResolvedValue(
createOwnerGame({ latestVersion: rejected, versions: [rejected] }),
);
render(
<GameDetailPage
gameId="game-1"
onBack={vi.fn()}
onPlay={vi.fn()}
ownerView
/>,
);
await screen.findByText('作品状态');
expect(
within(
document.querySelector('.game-detail-cover') as HTMLElement,
).getByText('审核未通过'),
).toBeTruthy();
expect(screen.queryByText('评分加载失败')).toBeNull();
});
it('以已发布版本作为在线版本,并为未完成版本回读下一步动作', async () => {
getMyGameMock.mockResolvedValue(
createOwnerGame({
@@ -314,6 +423,11 @@ describe('GameDetailPage 作者视角', () => {
expect(await screen.findByText('下一步:等待审核结果')).toBeTruthy();
expect(loadVersionRecoveryActionsMock).toHaveBeenCalledWith(['version-2']);
expect(within(screen.getByRole('list')).getByText('v1')).toBeTruthy();
// 已公开作品照旧请求评价与评分,门禁只作用于非公开作品。
await waitFor(() =>
expect(listGameReviewsMock).toHaveBeenCalledWith('game-1', 1),
);
expect(getMyGameReviewMock).toHaveBeenCalledWith('game-1');
const playButton = screen.getByRole('button', { name: /立即玩/u });
expect((playButton as HTMLButtonElement).disabled).toBe(false);
});
@@ -678,9 +792,7 @@ describe('游戏封面与截图展示', () => {
await screen.findByRole('heading', { name: '星轨防线' });
const heroImage = () =>
document
.querySelector('.game-detail-cover img')
?.getAttribute('src');
document.querySelector('.game-detail-cover img')?.getAttribute('src');
expect(heroImage()).toBe(
'https://assets.example.test/game-distribution/cover/alpha.png',
);
@@ -725,8 +837,8 @@ describe('游戏封面与截图展示', () => {
await screen.findByRole('heading', { name: '星轨防线' });
expect(document.querySelectorAll('.game-detail-cover img')).toHaveLength(0);
expect(
document.querySelectorAll('.game-detail-cover__icon'),
).toHaveLength(1);
expect(document.querySelectorAll('.game-detail-cover__icon')).toHaveLength(
1,
);
});
});
@@ -84,9 +84,16 @@ function deferred<T>() {
});
return { promise, resolve, reject };
}
function Harness() {
const state = useGameReviews('game-1', true);
return <GameReviews state={state} canReview onLogin={mocks.login} />;
function Harness({ publiclyVisible = true }: { publiclyVisible?: boolean }) {
const state = useGameReviews('game-1', true, publiclyVisible);
return (
<GameReviews
state={state}
canReview
onLogin={mocks.login}
publiclyVisible={publiclyVisible}
/>
);
}
async function form() {
return screen.findByRole('textbox', { name: '评论(可选)' });
@@ -280,6 +287,21 @@ describe('游戏评价', () => {
await screen.findByText('暂无评分');
});
it('非公开作品不请求评价与评分接口,两栏直接是暂无评价', async () => {
render(<Harness publiclyVisible={false} />);
await screen.findByText('全部评价');
expect(screen.getAllByText('暂无评价')).toHaveLength(2);
expect(screen.queryByText('评分加载中…')).toBeNull();
expect(screen.queryByText('评分加载失败')).toBeNull();
expect(screen.queryByRole('button', { name: '重试' })).toBeNull();
expect(screen.queryByRole('button', { name: '登录后评价' })).toBeNull();
expect(screen.queryByText('正在加载我的评价…')).toBeNull();
expect(screen.queryByRole('textbox')).toBeNull();
expect(mocks.list).not.toHaveBeenCalled();
expect(mocks.mine).not.toHaveBeenCalled();
});
it('服务端分页保留总数,修改保留当前页、首次提交回第一页', async () => {
mocks.mine.mockResolvedValue({ review: review() });
mocks.list.mockImplementation(async (_id, page) =>
@@ -107,10 +107,16 @@ export function GameReviews({
state,
canReview,
onLogin,
publiclyVisible,
}: {
state: ReviewsState;
canReview: boolean;
onLogin: () => void;
/**
* 作品是否已公开。非公开作品没有公开评价投影,两栏一律按空态展示,
* 不出现错误块、重试按钮或评分加载失败。
*/
publiclyVisible: boolean;
}) {
const length = reviewCommentLength(state.comment);
const tooLong = length > GAME_REVIEW_COMMENT_LIMIT;
@@ -119,7 +125,9 @@ export function GameReviews({
<section className="game-reviews" aria-label="游戏评价">
<div className="game-reviews__personal">
<h2>我的评价</h2>
{!canReview ? (
{!publiclyVisible ? (
<PlatformEmptyState>暂无评价</PlatformEmptyState>
) : !canReview ? (
<PlatformActionButton tone="secondary" onClick={onLogin}>
登录后评价
</PlatformActionButton>
@@ -218,9 +226,11 @@ export function GameReviews({
<div className="game-reviews__public">
<div className="game-reviews__heading">
<h2>全部评价</h2>
<GameRatingSummary state={state} />
{publiclyVisible ? <GameRatingSummary state={state} /> : null}
</div>
{state.listLoading ? (
{!publiclyVisible ? (
<PlatformEmptyState>暂无评价</PlatformEmptyState>
) : state.listLoading ? (
<p role="status">正在加载评价…</p>
) : state.listError ? (
<ReviewError
@@ -236,27 +246,31 @@ export function GameReviews({
) : (
<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>
{publiclyVisible ? (
<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>
) : null}
</div>
</section>
);
@@ -366,6 +366,22 @@
font-size: 0.75rem;
font-weight: 800;
}
/*
* 非公开作品的封面占位文案:封面素材不可换签时,封面位改为状态说明
* (审核中 / 审核未通过 / 未公开 / 已被封禁),压在渐变占位之上。
*/
.game-detail-cover__pending {
position: absolute;
inset: 0;
z-index: 3;
display: grid;
place-items: center;
background: rgba(22, 21, 28, 0.55);
color: #fff5dc;
font-size: 0.95rem;
font-weight: 800;
letter-spacing: 0.16em;
}
.game-loading-panel {
display: grid;
min-height: 18rem;
@@ -23,6 +23,31 @@ export const VERSION_STATUS_LABEL: Record<string, string> = {
revoked: '已撤回',
};
/**
* 非公开作品在封面位置显示的占位文案。
*
* 作品没公开时封面素材不可换签(封面位只会剩渐变占位),这里按最新版本状态给出最贴近的
* 说明;其余状态退回作品级公开文案,避免在封面上编造审核结论。
*/
export const NON_PUBLIC_COVER_VERSION_LABEL: Partial<
Record<GameDistributionVersionStatus, string>
> = {
pending_review: '审核中',
rejected: '审核未通过',
};
/** 非公开作品的封面占位文案:优先最新版本状态,其次作品公开状态。 */
export function resolveNonPublicCoverLabel(
gameStatus: string,
versionStatus?: GameDistributionVersionStatus | null,
): string {
return (
(versionStatus ? NON_PUBLIC_COVER_VERSION_LABEL[versionStatus] : null) ??
GAME_STATUS_LABEL[gameStatus] ??
'未公开'
);
}
/** 服务端 `recoveryAction` 的中文说明;前端只翻译,不推断状态。 */
export const RECOVERY_ACTION_LABEL: Record<
GameDistributionRecoveryAction,
@@ -23,8 +23,17 @@ function errorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}
/** 外层以游戏和账号身份为 key,切换上下文会卸载并清空整个评价草稿。 */
export function useGameReviews(gameId: string, canReview: boolean) {
/**
* 外层以游戏和账号身份为 key,切换上下文会卸载并清空整个评价草稿。
*
* `publiclyVisible` 为 false(审核中 / 审核未通过 / 未公开 / 已下架)时,服务端对评价与
* 评分一律按「作品不存在或不可公开访问」拒绝,前端直接不发起任何评价类请求,按空态展示。
*/
export function useGameReviews(
gameId: string,
canReview: boolean,
publiclyVisible: boolean,
) {
const [summary, setSummary] = useState<GameDistributionRatingSummary | null>(
null,
);
@@ -32,10 +41,10 @@ export function useGameReviews(gameId: string, canReview: boolean) {
null,
);
const [page, setPage] = useState(1);
const [listLoading, setListLoading] = useState(true);
const [listLoading, setListLoading] = useState(publiclyVisible);
const [listError, setListError] = useState('');
const [myReview, setMyReview] = useState<GameDistributionReview | null>(null);
const [myLoading, setMyLoading] = useState(canReview);
const [myLoading, setMyLoading] = useState(canReview && publiclyVisible);
const [myError, setMyError] = useState('');
const [editing, setEditing] = useState(false);
const [score, setScore] = useState<number | null>(null);
@@ -50,6 +59,8 @@ export function useGameReviews(gameId: string, canReview: boolean) {
const loadPage = useCallback(
async (nextPage: number) => {
// 非公开作品没有公开评价投影:不请求、不置错误态。
if (!publiclyVisible) return;
const serial = ++listSerial.current;
pageRef.current = nextPage;
setPage(nextPage);
@@ -68,11 +79,11 @@ export function useGameReviews(gameId: string, canReview: boolean) {
setListLoading(false);
}
},
[gameId],
[gameId, publiclyVisible],
);
const loadMine = useCallback(async () => {
if (!canReview) return;
if (!canReview || !publiclyVisible) return;
const serial = ++mySerial.current;
setMyLoading(true);
setMyError('');
@@ -86,18 +97,28 @@ export function useGameReviews(gameId: string, canReview: boolean) {
} finally {
if (alive.current && serial === mySerial.current) setMyLoading(false);
}
}, [canReview, gameId]);
}, [canReview, gameId, publiclyVisible]);
useEffect(() => {
alive.current = true;
void loadPage(1);
void loadMine();
if (!publiclyVisible) {
// 非公开作品:不进任何评价/评分请求,按空态起步。
listSerial.current += 1;
mySerial.current += 1;
setListLoading(false);
setMyLoading(false);
setListError('');
setMyError('');
} else {
void loadPage(1);
void loadMine();
}
return () => {
alive.current = false;
listSerial.current += 1;
mySerial.current += 1;
};
}, [loadMine, loadPage]);
}, [loadMine, loadPage, publiclyVisible]);
function edit() {
if (!myReview || savingRef.current) return;
@@ -116,7 +137,14 @@ export function useGameReviews(gameId: string, canReview: boolean) {
}
async function submit() {
if (savingRef.current || !canReview || myLoading || myError) return;
if (
savingRef.current ||
!canReview ||
!publiclyVisible ||
myLoading ||
myError
)
return;
const normalizedComment = normalizeReviewNewlines(comment);
if (score === null || !Number.isInteger(score) || score < 1 || score > 10) {
setSaveError('请选择 1–10 分');