合并 origin/master 到作品管理分支
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user