) : null}
-
+
{' '}
游玩时可以切换全屏,游戏运行在独立的安全容器中。
diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx
index 45be877b9..5c36f37c2 100644
--- a/src/components/game-distribution/GameDistributionPages.test.tsx
+++ b/src/components/game-distribution/GameDistributionPages.test.tsx
@@ -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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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,
+ );
});
});
diff --git a/src/components/game-distribution/GameReviews.test.tsx b/src/components/game-distribution/GameReviews.test.tsx
index c7f54963f..98ce40718 100644
--- a/src/components/game-distribution/GameReviews.test.tsx
+++ b/src/components/game-distribution/GameReviews.test.tsx
@@ -84,9 +84,16 @@ function deferred() {
});
return { promise, resolve, reject };
}
-function Harness() {
- const state = useGameReviews('game-1', true);
- return ;
+function Harness({ publiclyVisible = true }: { publiclyVisible?: boolean }) {
+ const state = useGameReviews('game-1', true, publiclyVisible);
+ return (
+
+ );
}
async function form() {
return screen.findByRole('textbox', { name: '评论(可选)' });
@@ -280,6 +287,21 @@ describe('游戏评价', () => {
await screen.findByText('暂无评分');
});
+ it('非公开作品不请求评价与评分接口,两栏直接是暂无评价', async () => {
+ render();
+
+ 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) =>
diff --git a/src/components/game-distribution/GameReviews.tsx b/src/components/game-distribution/GameReviews.tsx
index 34e11723d..00d215f7c 100644
--- a/src/components/game-distribution/GameReviews.tsx
+++ b/src/components/game-distribution/GameReviews.tsx
@@ -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({
我的评价
- {!canReview ? (
+ {!publiclyVisible ? (
+
暂无评价
+ ) : !canReview ? (
登录后评价
@@ -218,9 +226,11 @@ export function GameReviews({
全部评价
-
+ {publiclyVisible ? : null}
- {state.listLoading ? (
+ {!publiclyVisible ? (
+
暂无评价
+ ) : state.listLoading ? (
正在加载评价…
) : state.listError ? (
暂无评价
)}
-
+ {publiclyVisible ? (
+
+ ) : null}
);
diff --git a/src/components/game-distribution/gameDistribution.css b/src/components/game-distribution/gameDistribution.css
index 2ec1a61ac..ea2a86285 100644
--- a/src/components/game-distribution/gameDistribution.css
+++ b/src/components/game-distribution/gameDistribution.css
@@ -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;
diff --git a/src/components/game-distribution/gameVersionHistoryModel.ts b/src/components/game-distribution/gameVersionHistoryModel.ts
index d96fcf3d5..e5d10b63a 100644
--- a/src/components/game-distribution/gameVersionHistoryModel.ts
+++ b/src/components/game-distribution/gameVersionHistoryModel.ts
@@ -23,6 +23,31 @@ export const VERSION_STATUS_LABEL: Record
= {
revoked: '已撤回',
};
+/**
+ * 非公开作品在封面位置显示的占位文案。
+ *
+ * 作品没公开时封面素材不可换签(封面位只会剩渐变占位),这里按最新版本状态给出最贴近的
+ * 说明;其余状态退回作品级公开文案,避免在封面上编造审核结论。
+ */
+export const NON_PUBLIC_COVER_VERSION_LABEL: Partial<
+ Record
+> = {
+ 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,
diff --git a/src/components/game-distribution/useGameReviews.ts b/src/components/game-distribution/useGameReviews.ts
index 88f5ded33..0b5ebd55d 100644
--- a/src/components/game-distribution/useGameReviews.ts
+++ b/src/components/game-distribution/useGameReviews.ts
@@ -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(
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(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(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 分');