合并 origin/master 到作品管理分支

This commit is contained in:
2026-10-02 15:12:52 +08:00
184 changed files with 14764 additions and 1244 deletions
@@ -24,6 +24,7 @@ import {
getMyGame,
loadVersionRecoveryActions,
} from '../../services/gameDistributionClient';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import {
@@ -33,12 +34,14 @@ import {
} from './gameDistributionGuards';
import { GameCover } from './GameGalleryPage';
import { GAME_METADATA_INPUT_MODE_LABELS } from './gameMetadataOptions';
import { GameRatingSummary, GameReviews } from './GameReviews';
import { GameVersionHistory } from './GameVersionHistory';
import {
GAME_STATUS_LABEL,
VERSION_STATUS_LABEL,
VERSION_STATUS_NEEDS_AUTHOR_ACTION,
} from './gameVersionHistoryModel';
import { useGameReviews } from './useGameReviews';
type GameDetailPageProps = {
gameId: string | null;
@@ -157,6 +160,7 @@ export function GameDetailPage({
onPlay,
ownerView = false,
}: GameDetailPageProps) {
const authUi = useAuthUi();
const [publicGame, setPublicGame] = useState<GameDistributionGame | null>(
null,
);
@@ -267,16 +271,19 @@ export function GameDetailPage({
};
}, [loadOwnerGame, loadPublicGame, ownerView]);
const game = ownerView ? ownerGame : publicGame;
const resolvedGame = ownerView ? ownerGame : publicGame;
const reload = ownerView ? loadOwnerGame : loadPublicGame;
if (isLoading)
if (
isLoading ||
(resolvedGame && resolvedGame.id !== normalizeGameId(gameId))
)
return (
<div className="game-detail-page">
<div className="game-loading-panel">正在加载游戏详情…</div>
</div>
);
if (error || !game)
if (error || !resolvedGame)
return (
<div className="game-detail-page">
<button type="button" className="game-back-button" onClick={onBack}>
@@ -295,6 +302,45 @@ export function GameDetailPage({
</div>
);
const canReview = Boolean(authUi?.user && authUi.canAccessProtectedData);
return (
<GameDetailContent
key={`${resolvedGame.id}:${authUi?.user?.id ?? 'guest'}:${canReview}`}
game={resolvedGame}
ownerView={ownerView}
ownerGame={ownerGame}
recoveryActions={recoveryActions}
isMobileViewport={isMobileViewport}
canReview={canReview}
onLogin={() => authUi?.openLoginModal()}
onBack={onBack}
onPlay={onPlay}
/>
);
}
function GameDetailContent({
game,
ownerView,
ownerGame,
recoveryActions,
isMobileViewport,
canReview,
onLogin,
onBack,
onPlay,
}: {
game: GameDistributionGame;
ownerView: boolean;
ownerGame: GameDistributionMyGame | null;
recoveryActions: Record<string, GameDistributionRecoveryAction>;
isMobileViewport: boolean;
canReview: boolean;
onLogin: () => void;
onBack: () => void;
onPlay: (gameId: string) => void;
}) {
const reviews = useGameReviews(game.id, canReview);
/** 作者视角的条目没有公开投影 `currentVersion`,可玩版本取版本历史里的已发布版本。 */
const publishedVersion =
ownerGame?.versions.find((item) => item.status === 'published') ?? null;
@@ -321,6 +367,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>
@@ -423,6 +472,7 @@ export function GameDetailPage({
</div>
</aside>
</section>
<GameReviews state={reviews} canReview={canReview} onLogin={onLogin} />
<p className="game-detail-footnote">
<Maximize2 aria-hidden="true" />{' '}
游玩时可以切换全屏,游戏运行在独立的安全容器中。
@@ -31,6 +31,14 @@ vi.mock('../../services/gameDistributionClient', () => ({
getGame: getGameMock,
getMyGame: getMyGameMock,
loadVersionRecoveryActions: loadVersionRecoveryActionsMock,
listGameReviews: vi.fn().mockResolvedValue({
reviews: [],
page: 1,
pageSize: 20,
total: 0,
totalPages: 0,
ratingSummary: { averageScore: null, ratingCount: 0 },
}),
}));
// 只替换换签结果,测试不发起任何真实请求;返回空串即代表签名失败或字段为空。
@@ -73,6 +81,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',
@@ -121,6 +130,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,
};
}
@@ -18,6 +18,7 @@ const callbacks = {
onOpenApiKeys: vi.fn(),
onOpenCommunity: vi.fn(),
onOpenFeedback: vi.fn(),
onOpenInvite: vi.fn(),
onOpenRecharge: vi.fn(),
onOpenRewardCode: vi.fn(),
onOpenSettings: vi.fn(),
@@ -90,6 +91,7 @@ describe('PlatformActiveProfileView', () => {
expect(screen.getByText(/陶泥号:\s*100001/u)).toBeTruthy();
expect(screen.getByRole('button', { name: '泥点余额 108' })).toBeTruthy();
expect(screen.getByRole('button', { name: /泥点充值/u })).toBeTruthy();
expect(screen.getByRole('button', { name: /邀请好友/u })).toBeTruthy();
expect(screen.getByRole('button', { name: /兑换码/u })).toBeTruthy();
expect(screen.getByRole('button', { name: /玩家社区/u })).toBeTruthy();
expect(screen.getByRole('button', { name: /反馈与建议/u })).toBeTruthy();
@@ -98,6 +100,9 @@ describe('PlatformActiveProfileView', () => {
screen.getByRole('button', { name: /开发者 API Key/u }),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /邀请好友/u }));
expect(callbacks.onOpenInvite).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: '用户协议' }));
const legalDialog = screen.getByRole('dialog', { name: '用户协议' });
expect(legalDialog.parentElement?.className).toContain(
@@ -7,6 +7,7 @@ import {
Pencil,
Settings,
Ticket,
UserPlus,
UserRound,
} from 'lucide-react';
import { useCallback, useRef, useState } from 'react';
@@ -21,6 +22,7 @@ import profileClockImage from '../../../media/profile/_Image (1).png';
import profileGamepadImage from '../../../media/profile/_Image (2).png';
import profileStillLifeImage from '../../../media/profile/_Image (3).png';
import profileCoinsImage from '../../../media/profile/_Image (4).png';
import profileInviteImage from '../../../media/profile/_Image (5).png';
import profileGiftImage from '../../../media/profile/_Image (6).png';
import profileCommunityImage from '../../../media/profile/_Image (7).png';
import profileFeedbackImage from '../../../media/profile/_Image (8).png';
@@ -69,6 +71,7 @@ type PlatformActiveProfileViewProps = {
onOpenApiKeys: () => void;
onOpenCommunity: () => void;
onOpenFeedback: () => void;
onOpenInvite: () => void;
onOpenRecharge: () => void;
onOpenRewardCode: () => void;
onOpenSettings: () => void;
@@ -278,6 +281,7 @@ export function PlatformActiveProfileView({
onOpenApiKeys,
onOpenCommunity,
onOpenFeedback,
onOpenInvite,
onOpenRecharge,
onOpenRewardCode,
onOpenSettings,
@@ -606,7 +610,7 @@ export function PlatformActiveProfileView({
className="platform-profile-shortcut-panel"
aria-label="常用功能"
>
<div className="platform-profile-shortcut-grid grid w-full grid-cols-4">
<div className="platform-profile-shortcut-grid grid w-full grid-cols-5">
<ProfileShortcutButton
label="泥点充值"
subLabel="充值泥点"
@@ -614,6 +618,13 @@ export function PlatformActiveProfileView({
imageSrc={profileCoinsImage}
onClick={onOpenRecharge}
/>
<ProfileShortcutButton
label="邀请好友"
subLabel="双方得泥点"
icon={UserPlus}
imageSrc={profileInviteImage}
onClick={onOpenInvite}
/>
<ProfileShortcutButton
label="兑换码"
subLabel="领取福利"
@@ -19,6 +19,7 @@ import {
import { PlatformMudPointWalletEntry } from '../../../packages/shared/src/components/PlatformMudPointWalletEntry';
import { PlatformProfileRechargeModal } from '../../../packages/shared/src/components/PlatformProfileRechargeModal';
import { PlatformProfileReferralModal } from '../../../packages/shared/src/components/PlatformProfileReferralModal';
import { PlatformProfileWalletLedgerModal } from '../../../packages/shared/src/components/PlatformProfileWalletLedgerModal';
import type { ProfileDashboardSummary } from '../../../packages/shared/src/contracts/runtime';
import {
@@ -46,7 +47,10 @@ import { PlatformActiveProfileView } from './PlatformActiveProfileView';
import type { PlatformEntryFlowShellProps } from './platformEntryActiveTypes';
import { usePlatformDesktopLayout } from './platformEntryResponsive';
import { PlatformProfileApiKeysModal } from './PlatformProfileApiKeysModal';
import { PlatformProfileReferralModal } from './PlatformProfileReferralModal';
import {
canPasteFromNativeHostClipboard,
readNativeHostClipboardText,
} from './platformProfileHostClipboard';
import { PlatformProfileRewardCodeRedeemModal } from './PlatformProfileRewardCodeRedeemModal';
import {
PlatformRechargePaymentConfirmationMask,
@@ -515,6 +519,20 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage('project', { path: '/project' });
}, [setSelectionStage]);
/**
* 「填邀请码」的粘贴动作。
*
* 宿主能力留在宿主:共享弹层只按 `canPasteRedeemCode` 决定是否渲染按钮,读取宿主
* 剪贴板由主站自己完成,读到文本只填进输入框,不自动提交。
*/
const pasteReferralRedeemCode = useCallback(() => {
void readNativeHostClipboardText().then((text) => {
if (text) {
profileCenter.setReferralRedeemCode(text);
}
});
}, [profileCenter]);
const isMobileToolStage =
!isDesktopLayout &&
(selectionStage === 'creation-home' ||
@@ -770,6 +788,9 @@ export function PlatformEntryFlowShellImpl({
profileCenter.openProfilePopupPanel('community')
}
onOpenFeedback={openFeedback}
onOpenInvite={() =>
profileCenter.openProfilePopupPanel('invite')
}
onOpenRecharge={openRecharge}
onOpenRewardCode={profileCenter.openRewardCodeModal}
onOpenSettings={() => authUi?.openSettingsModal()}
@@ -881,14 +902,19 @@ export function PlatformEntryFlowShellImpl({
<PlatformProfileReferralModal
panel={profileCenter.profilePopupPanel}
center={profileCenter.referralCenter}
inviteLinkUrl={profileCenter.inviteLinkUrl}
isLoading={profileCenter.isLoadingReferral}
isSubmittingRedeem={profileCenter.isSubmittingReferralRedeem}
redeemCode={profileCenter.referralRedeemCode}
copyInviteState={profileCenter.inviteCopyState}
copyInviteLinkState={profileCenter.inviteLinkCopyState}
copyInviteCodeState={profileCenter.inviteCodeCopyState}
error={profileCenter.referralError}
success={profileCenter.referralSuccess}
canPasteRedeemCode={canPasteFromNativeHostClipboard()}
onPasteRedeemCode={pasteReferralRedeemCode}
onClose={profileCenter.closeProfilePopupPanel}
onCopyInvite={profileCenter.copyInviteInfo}
onCopyInviteLink={profileCenter.copyInviteLink}
onCopyInviteCode={profileCenter.copyInviteCode}
onRedeemCodeChange={profileCenter.setReferralRedeemCode}
onSubmitRedeemCode={profileCenter.submitReferralRedeemCode}
/>
@@ -1,172 +0,0 @@
/* @vitest-environment jsdom */
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { ProfileReferralInviteCenterResponse } from '../../../packages/shared/src/contracts/runtime';
import {
canPasteFromNativeHostClipboard,
readNativeHostClipboardText,
} from './platformProfileHostClipboard';
import { PlatformProfileReferralModal } from './PlatformProfileReferralModal';
vi.mock('./platformProfileHostClipboard', () => ({
canPasteFromNativeHostClipboard: vi.fn(() => false),
readNativeHostClipboardText: vi.fn(),
}));
const canPasteFromNativeHostClipboardMock = vi.mocked(
canPasteFromNativeHostClipboard,
);
const readNativeHostClipboardTextMock = vi.mocked(readNativeHostClipboardText);
function buildCenter(
overrides: Partial<ProfileReferralInviteCenterResponse> = {},
): ProfileReferralInviteCenterResponse {
return {
inviteCode: 'ABCD1234',
inviteLinkPath: '/invite/ABCD1234',
invitedCount: 1,
rewardedInviteCount: 1,
todayInviterRewardCount: 1,
todayInviterRewardRemaining: 9,
rewardPoints: 66,
invitedUsers: [
{
userId: 'user-2',
displayName: '海盐',
avatarUrl: null,
boundAt: '2026-06-10T08:00:00.000Z',
},
],
hasRedeemedCode: false,
boundInviterUserId: null,
boundAt: null,
updatedAt: '2026-06-10T08:00:00.000Z',
...overrides,
};
}
describe('PlatformProfileReferralModal', () => {
afterEach(() => {
vi.clearAllMocks();
canPasteFromNativeHostClipboardMock.mockReturnValue(false);
});
test('renders invite panel with shared profile content', () => {
render(
<PlatformProfileReferralModal
panel="invite"
center={buildCenter()}
isLoading={false}
isSubmittingRedeem={false}
redeemCode=""
copyInviteState="idle"
error={null}
success={null}
onClose={vi.fn()}
onCopyInvite={vi.fn()}
onRedeemCodeChange={vi.fn()}
onSubmitRedeemCode={vi.fn()}
/>,
);
const dialog = screen.getByRole('dialog', { name: '邀请好友' });
expect(within(dialog).getByText('邀请码')).toBeTruthy();
expect(within(dialog).getByText('ABCD1234')).toBeTruthy();
expect(within(dialog).getByText('海盐')).toBeTruthy();
expect(within(dialog).getByText('成功邀请')).toBeTruthy();
expect(
within(dialog).getByRole('button', { name: /复制邀请/u }),
).toBeTruthy();
});
test('submits redeem panel with the shared form shell', async () => {
const user = userEvent.setup();
const onRedeemCodeChange = vi.fn();
const onSubmitRedeemCode = vi.fn();
render(
<PlatformProfileReferralModal
panel="redeem"
center={buildCenter()}
isLoading={false}
isSubmittingRedeem={false}
redeemCode="ab12"
copyInviteState="idle"
error={null}
success={null}
onClose={vi.fn()}
onCopyInvite={vi.fn()}
onRedeemCodeChange={onRedeemCodeChange}
onSubmitRedeemCode={onSubmitRedeemCode}
/>,
);
const dialog = screen.getByRole('dialog', { name: '填邀请码' });
const input = within(dialog).getByRole('textbox', { name: '邀请码' });
await user.type(input, ' c');
expect(onRedeemCodeChange).toHaveBeenCalled();
await user.click(within(dialog).getByRole('button', { name: '提交' }));
expect(onSubmitRedeemCode).toHaveBeenCalledTimes(1);
});
test('redeem panel can paste invite code from native host clipboard', async () => {
const user = userEvent.setup();
const onRedeemCodeChange = vi.fn();
canPasteFromNativeHostClipboardMock.mockReturnValue(true);
readNativeHostClipboardTextMock.mockResolvedValue('spring-2026');
render(
<PlatformProfileReferralModal
panel="redeem"
center={buildCenter()}
isLoading={false}
isSubmittingRedeem={false}
redeemCode=""
copyInviteState="idle"
error={null}
success={null}
onClose={vi.fn()}
onCopyInvite={vi.fn()}
onRedeemCodeChange={onRedeemCodeChange}
onSubmitRedeemCode={vi.fn()}
/>,
);
await user.click(screen.getByRole('button', { name: '粘贴' }));
expect(readNativeHostClipboardTextMock).toHaveBeenCalledTimes(1);
expect(onRedeemCodeChange).toHaveBeenCalledWith('spring-2026');
});
test('renders community QR panels', () => {
render(
<PlatformProfileReferralModal
panel="community"
center={buildCenter()}
isLoading={false}
isSubmittingRedeem={false}
redeemCode=""
copyInviteState="idle"
error={null}
success={null}
onClose={vi.fn()}
onCopyInvite={vi.fn()}
onRedeemCodeChange={vi.fn()}
onSubmitRedeemCode={vi.fn()}
/>,
);
const dialog = screen.getByRole('dialog', { name: '玩家社区' });
expect(within(dialog).getByAltText('玩家社区微信群二维码')).toBeTruthy();
expect(within(dialog).getByAltText('玩家社区 QQ 群二维码')).toBeTruthy();
expect(within(dialog).getByText('微信群')).toBeTruthy();
expect(within(dialog).getByText('QQ群')).toBeTruthy();
});
});
@@ -1,343 +0,0 @@
import { ClipboardPaste, Copy } from 'lucide-react';
import type { ReactNode } from 'react';
import communityQqQrImage from '../../../media/social-media-group/qq.png';
import communityWechatQrImage from '../../../media/social-media-group/wechat.png';
import type { ProfileReferralInviteCenterResponse } from '../../../packages/shared/src/contracts/runtime';
import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformProfileContentRow } from '../common/PlatformProfileContentRow';
import { PlatformProfileSkeletonList } from '../common/PlatformProfileSkeletonList';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { PlatformTextField } from '../common/PlatformTextField';
import type { CopyFeedbackState } from '../common/useCopyFeedback';
import {
canPasteFromNativeHostClipboard,
readNativeHostClipboardText,
} from './platformProfileHostClipboard';
import { PlatformProfileSecondaryModalShell } from './PlatformProfileModalShell';
import type { ProfileReferralPanel } from './usePlatformProfileCenterController';
type PlatformProfileReferralModalProps = {
panel: ProfileReferralPanel;
center: ProfileReferralInviteCenterResponse | null;
isLoading: boolean;
isSubmittingRedeem: boolean;
redeemCode: string;
copyInviteState: CopyFeedbackState;
error: string | null;
success: string | null;
onClose: () => void;
onCopyInvite: () => void;
onRedeemCodeChange: (value: string) => void;
onSubmitRedeemCode: () => void;
};
const COMMUNITY_QR_CODES = [
{
label: '微信群',
src: communityWechatQrImage,
alt: '玩家社区微信群二维码',
},
{
label: 'QQ群',
src: communityQqQrImage,
alt: '玩家社区 QQ 群二维码',
},
] as const;
function ProfileReferralUserAvatar({
name,
avatarUrl,
}: {
name: string;
avatarUrl: string | null;
}) {
const avatarLabel = (name.trim() || '玩').slice(0, 1).toUpperCase();
return (
<span className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#ff4056] text-xs font-black text-white">
{avatarUrl ? (
<img
src={avatarUrl}
alt=""
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
) : (
avatarLabel
)}
</span>
);
}
function resolvePanelTitle(panel: ProfileReferralPanel) {
if (panel === 'invite') {
return '邀请好友';
}
if (panel === 'redeem') {
return '填邀请码';
}
return '玩家社区';
}
/**
* 个人中心邀请能力统一弹层。
* 承接邀请码、填码和社区二维码三种 profile panel,避免首页继续内联重复白底浮层实现。
*/
export function PlatformProfileReferralModal({
panel,
center,
isLoading,
isSubmittingRedeem,
redeemCode,
copyInviteState,
error,
success,
onClose,
onCopyInvite,
onRedeemCodeChange,
onSubmitRedeemCode,
}: PlatformProfileReferralModalProps) {
const title = resolvePanelTitle(panel);
const normalizedRedeemCode = redeemCode
.trim()
.replace(/[^0-9a-z]/gi, '')
.toUpperCase();
const canPasteRedeemCode = canPasteFromNativeHostClipboard();
const pasteRedeemCode = () => {
void readNativeHostClipboardText().then((text) => {
if (text) {
onRedeemCodeChange(text);
}
});
};
let content: ReactNode;
if (panel === 'community') {
content = (
<div className="mt-5 grid grid-cols-2 gap-3">
{COMMUNITY_QR_CODES.map((qrCode) => (
<PlatformSubpanel
as="div"
key={qrCode.label}
surface="flat"
radius="xs"
padding="xs"
className="text-center"
>
<div className="aspect-square overflow-hidden rounded-lg border border-zinc-200 bg-white p-1.5">
<img
src={qrCode.src}
alt={qrCode.alt}
className="h-full w-full object-contain"
loading="lazy"
decoding="async"
/>
</div>
<div className="mt-2 text-sm font-bold text-zinc-700">
{qrCode.label}
</div>
</PlatformSubpanel>
))}
</div>
);
} else if (panel === 'redeem') {
content = (
<PlatformAsyncStatePanel
isLoading={isLoading}
loadingState={
<PlatformProfileSkeletonList
count={2}
containerClassName="mt-5 space-y-3"
itemClassName="h-12 even:h-11"
/>
}
isEmpty={Boolean(center?.hasRedeemedCode)}
emptyState={
<PlatformEmptyState
surface="subpanel"
size="inline"
tone="base"
className="mt-5"
>
已填写邀请码
</PlatformEmptyState>
}
>
<form
className="mt-5 space-y-3"
onSubmit={(event) => {
event.preventDefault();
onSubmitRedeemCode();
}}
>
<PlatformTextField
value={redeemCode}
onChange={(event) => onRedeemCodeChange(event.target.value)}
size="lg"
density="roomy"
tone="rose"
className="rounded-xl text-center font-black uppercase tracking-[0.16em]"
placeholder="邀请码"
aria-label="邀请码"
autoComplete="off"
autoFocus
/>
{canPasteRedeemCode ? (
<PlatformActionButton
type="button"
surface="profile"
tone="ghost"
size="xs"
shape="pill"
className="w-full"
onClick={pasteRedeemCode}
>
<ClipboardPaste className="h-3.5 w-3.5" />
粘贴
</PlatformActionButton>
) : null}
<PlatformActionButton
type="submit"
surface="profile"
fullWidth
size="md"
className="rounded-xl"
disabled={isSubmittingRedeem || !normalizedRedeemCode}
>
{isSubmittingRedeem ? '提交中' : '提交'}
</PlatformActionButton>
</form>
</PlatformAsyncStatePanel>
);
} else {
content = (
<PlatformAsyncStatePanel
isLoading={isLoading}
loadingState={
<PlatformProfileSkeletonList
count={2}
containerClassName="mt-5 space-y-3"
itemClassName="h-20 odd:h-20 even:h-10"
/>
}
>
<div className="mt-5 space-y-3">
<PlatformSubpanel
as="div"
surface="flat"
radius="xs"
padding="md"
className="text-center"
>
<PlatformFieldLabel
variant="section"
className="block text-[11px] text-zinc-500"
>
邀请码
</PlatformFieldLabel>
<div className="mt-1 text-3xl font-black tracking-[0.16em] text-[#ff4056]">
{center?.inviteCode ?? '--------'}
</div>
</PlatformSubpanel>
<PlatformStatusMessage
tone="warning"
surface="profile"
size="md"
className="space-y-0.5 px-3.5 font-semibold"
>
<div>
{`邀请一个用户注册,双方都可以获得${center?.rewardPoints ?? 30}泥点。`}
</div>
<div>每日最多获得十次邀请奖励。</div>
</PlatformStatusMessage>
<CopyFeedbackButton
state={copyInviteState}
onClick={onCopyInvite}
disabled={!center?.inviteCode}
idleLabel="复制邀请"
copiedLabel="已复制"
failedLabel="复制失败"
idleIcon={<Copy className="h-4 w-4" />}
actionSurface="profile"
actionSize="md"
actionFullWidth
className="gap-2 rounded-xl"
/>
<PlatformSubpanel as="div" surface="flat" radius="xs" padding="sm">
<PlatformFieldLabel
variant="section"
className="block text-zinc-900"
>
成功邀请
</PlatformFieldLabel>
{center?.invitedUsers?.length ? (
<div className="mt-3 max-h-44 space-y-2 overflow-y-auto pr-1">
{center.invitedUsers.map((user) => (
<PlatformProfileContentRow
key={`${user.userId}-${user.boundAt}`}
surface="soft"
radius="xs"
padding="row"
className="flex items-center gap-3"
>
<ProfileReferralUserAvatar
name={user.displayName}
avatarUrl={user.avatarUrl}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-bold text-zinc-900">
{user.displayName || '玩家'}
</div>
</div>
</PlatformProfileContentRow>
))}
</div>
) : (
<PlatformEmptyState
surface="subpanel"
size="compact"
className="mt-3 text-center text-xs font-semibold leading-normal"
>
暂无成功邀请
</PlatformEmptyState>
)}
</PlatformSubpanel>
</div>
</PlatformAsyncStatePanel>
);
}
return (
<PlatformProfileSecondaryModalShell
title={title}
onClose={onClose}
closeVariant="floatingPlain"
closeIcon="×"
overlayTone="soft"
panelClassName="relative !max-w-[24rem] bg-white text-zinc-950 shadow-2xl !rounded-[1.35rem] sm:!rounded-[1.35rem]"
contentClassName="relative px-5 pb-5 pt-4"
>
<div className="text-center text-xl font-black">{title}</div>
{content}
{error ? (
<PlatformStatusMessage tone="error" className="mt-4">
{error}
</PlatformStatusMessage>
) : null}
{success ? (
<PlatformStatusMessage tone="success" className="mt-4">
{success}
</PlatformStatusMessage>
) : null}
</PlatformProfileSecondaryModalShell>
);
}
@@ -11,11 +11,62 @@ import {
userB,
} from './usePlatformProfileCenterController.testSupport';
const clipboardMock = vi.hoisted(() => ({
copyTextToClipboard: vi.fn(async () => true),
}));
vi.mock('../../services/clipboard', () => clipboardMock);
describe('usePlatformProfileCenterController redemption lifecycle', () => {
beforeEach(() => {
window.history.replaceState(null, '', '/profile');
usePlatformWalletStore.getState().resetWalletBalance();
vi.clearAllMocks();
clipboardMock.copyTextToClipboard.mockResolvedValue(true);
});
test('copies the invite link and the invite code as separate actions', async () => {
profileClientMocks.getPlatformProfileReferralInviteCenter.mockResolvedValue(
{
inviteCode: 'USER-A',
inviteLinkPath: '/?inviteCode=USER-A',
invitedCount: 0,
rewardedInviteCount: 0,
todayInviterRewardCount: 0,
todayInviterRewardRemaining: 10,
rewardPoints: 30,
invitedUsers: [],
hasRedeemedCode: false,
boundInviterUserId: null,
boundAt: null,
updatedAt: '2026-10-01T00:00:00.000Z',
},
);
const expectedInviteLink = `${window.location.origin}/?inviteCode=USER-A`;
const { result } = renderController(userA);
await act(async () => {
result.current.openProfilePopupPanel('invite');
});
expect(result.current.profilePopupPanel).toBe('invite');
expect(result.current.inviteLinkUrl).toBe(expectedInviteLink);
await act(async () => {
result.current.copyInviteLink();
});
expect(clipboardMock.copyTextToClipboard).toHaveBeenLastCalledWith(
expectedInviteLink,
);
expect(result.current.referralSuccess).toBe('邀请链接已复制');
await act(async () => {
result.current.copyInviteCode();
});
expect(clipboardMock.copyTextToClipboard).toHaveBeenLastCalledWith(
'USER-A',
);
expect(result.current.referralSuccess).toBe('邀请码已复制');
});
test('ignores a reward-code response after the account changes', async () => {
@@ -422,7 +422,9 @@ export function usePlatformProfileCenterController({
useState(false);
const [referralError, setReferralError] = useState<string | null>(null);
const [referralSuccess, setReferralSuccess] = useState<string | null>(null);
const { copyState: inviteCopyState, copyText: copyInviteText } =
const { copyState: inviteLinkCopyState, copyText: copyInviteLinkText } =
useCopyFeedback();
const { copyState: inviteCodeCopyState, copyText: copyInviteCodeText } =
useCopyFeedback();
const pendingWechatRechargeOrderRef =
useRef<WechatRechargeOrderLifecycle | null>(null);
@@ -1701,21 +1703,43 @@ export function usePlatformProfileCenterController({
setProfilePopupPanel(null);
}, []);
const copyInviteInfo = useCallback(() => {
if (!referralCenter?.inviteCode) {
/**
* 邀请链接是分享给好友的正式入口(好友打开后自动带上邀请码),
* 因此这里只用站点 origin 补全后端返回的相对路径。
*/
const inviteLinkUrl = useMemo(() => {
const inviteLinkPath = referralCenter?.inviteLinkPath;
if (!inviteLinkPath) {
return null;
}
if (typeof window === 'undefined') {
return inviteLinkPath;
}
try {
return new URL(inviteLinkPath, window.location.origin).href;
} catch {
return inviteLinkPath;
}
}, [referralCenter]);
const copyInviteLink = useCallback(() => {
if (!inviteLinkUrl) {
return;
}
void copyInviteLinkText(inviteLinkUrl).then((copied) => {
setReferralSuccess(copied ? '邀请链接已复制' : '复制失败');
});
}, [copyInviteLinkText, inviteLinkUrl]);
const inviteUrl =
typeof window === 'undefined'
? referralCenter.inviteLinkPath
: new URL(referralCenter.inviteLinkPath, window.location.origin).href;
void copyInviteText(`${referralCenter.inviteCode} ${inviteUrl}`).then(
(copied) => {
setReferralSuccess(copied ? '已复制' : '复制失败');
},
);
}, [copyInviteText, referralCenter]);
const copyInviteCode = useCallback(() => {
const inviteCode = referralCenter?.inviteCode;
if (!inviteCode) {
return;
}
void copyInviteCodeText(inviteCode).then((copied) => {
setReferralSuccess(copied ? '邀请码已复制' : '复制失败');
});
}, [copyInviteCodeText, referralCenter]);
const submitReferralRedeemCode = useCallback(() => {
const inviteCode = referralRedeemCode
@@ -1850,7 +1874,10 @@ export function usePlatformProfileCenterController({
closeNativeWechatPayment,
closeProfilePopupPanel,
confirmNativeWechatPayment,
inviteCopyState: inviteCopyState as CopyFeedbackState,
copyInviteCode,
inviteCodeCopyState: inviteCodeCopyState as CopyFeedbackState,
inviteLinkCopyState: inviteLinkCopyState as CopyFeedbackState,
inviteLinkUrl,
isLoadingRechargeCenter,
isLoadingReferral,
isLoadingWalletLedger,
@@ -1892,6 +1919,6 @@ export function usePlatformProfileCenterController({
walletLedgerError,
wechatRechargeOrderConfirmationState,
buyRechargeProduct,
copyInviteInfo,
copyInviteLink,
};
}
@@ -1,3 +1,4 @@
/* @vitest-environment jsdom */
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { clearStoredAccessToken, setStoredAccessToken } from './apiClient';
@@ -6,8 +7,11 @@ import {
createGameVersion,
getGame,
getGameVersion,
getMyGameReview,
listGameReviews,
listGames,
listMyGames,
saveMyGameReview,
unpublishGame,
uploadGamePackage,
} from './gameDistributionClient';
@@ -17,6 +21,40 @@ describe('gameDistributionClient', () => {
clearStoredAccessToken({ emit: false });
});
it('评价读取公开分页和私有鉴权、PUT 使用 envelope 且不缓存', async () => {
setStoredAccessToken('reviewer-token', { emit: false });
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(
JSON.stringify({
ok: true,
data: { review: null },
error: null,
meta: { apiVersion: 'v1' },
}),
{ status: 200 },
),
),
);
vi.stubGlobal('fetch', fetchMock);
await listGameReviews('game-1', 2);
expect(fetchMock.mock.calls[0][0]).toBe(
'/api/game-distribution/games/game-1/reviews?page=2&pageSize=20',
);
expect(fetchMock.mock.calls[0][1].headers.Authorization).toBeUndefined();
expect(fetchMock.mock.calls[0][1].cache).toBe('no-store');
expect(await getMyGameReview('game-1')).toEqual({ review: null });
expect(fetchMock.mock.calls[1][1].headers.Authorization).toBe(
'Bearer reviewer-token',
);
await saveMyGameReview('game-1', { score: 10, comment: '' });
expect(fetchMock.mock.calls[2][1]).toMatchObject({
method: 'PUT',
cache: 'no-store',
body: JSON.stringify({ score: 10, comment: '' }),
});
});
it('按关键词和分类读取后端目录', async () => {
vi.stubGlobal(
'fetch',
+35
View File
@@ -6,7 +6,11 @@ import type {
GameDistributionCreateVersionRequest,
GameDistributionDeviceSupport,
GameDistributionGame,
GameDistributionMyReviewResponse,
GameDistributionRecoveryAction,
GameDistributionReviewsResponse,
GameDistributionSaveReviewRequest,
GameDistributionSaveReviewResponse,
GameDistributionUpdateGameMetadataRequest,
GameDistributionVersionDetail,
GameDistributionVersionStatus as GameDistributionVersionStatusValue,
@@ -22,7 +26,10 @@ export type {
GameDistributionDeviceSupport,
GameDistributionGame,
GameDistributionInputMode,
GameDistributionRatingSummary,
GameDistributionRecoveryAction,
GameDistributionReview,
GameDistributionReviewsResponse,
GameDistributionVersionDetail,
GameDistributionVersionSummary,
} from '../../packages/shared/src/contracts/gameDistribution';
@@ -303,6 +310,34 @@ export async function getGame(gameId: string) {
);
}
export function listGameReviews(gameId: string, page = 1) {
return requestJson<GameDistributionReviewsResponse>(
`/games/${encodeURIComponent(gameId)}/reviews?page=${page}&pageSize=20`,
{ method: 'GET', cache: 'no-store' },
'读取游戏评价失败',
PUBLIC_GAME_REQUEST_OPTIONS,
);
}
export function getMyGameReview(gameId: string) {
return requestJson<GameDistributionMyReviewResponse>(
`/games/${encodeURIComponent(gameId)}/my-review`,
{ method: 'GET', cache: 'no-store' },
'读取我的评价失败',
);
}
export function saveMyGameReview(
gameId: string,
payload: GameDistributionSaveReviewRequest,
) {
return requestJson<GameDistributionSaveReviewResponse>(
`/games/${encodeURIComponent(gameId)}/my-review`,
{ method: 'PUT', cache: 'no-store', body: JSON.stringify(payload) },
'保存评价失败',
);
}
export async function createGame(
payload: GameDistributionCreateGameRequest,
idempotencyKey: string,