网页端收藏:作品详情页收藏切换 + 我的收藏页签
Project CI / AI game creator shell Rust crates (pull_request) Successful in 5m50s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m38s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m38s
Project CI / Frontend tests (pull_request) Failing after 1m10s
Project CI / Backend tests (pull_request) Successful in 9m34s
Project CI / AI game creator shell web tests (pull_request) Successful in 4m3s
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled

- 客户端:新增 setGameCollection(PUT 带 Idempotency-Key;DELETE 不带)与 listMyCollections({games,nextCursor},条目形状与公开目录逐字一致,可复用同一套卡片);getGame 返回类型放宽为 GameDistributionGameWithCollection,collected 为可选
- 详情页:在动作区(「立即玩」旁,而不是共创信息卡——那张卡讲改编关系)新增收藏切换。三态严格区分:collected=true → 「已收藏」点击取消;collected=false → 「收藏」点击收藏;**缺字段(匿名 / 无效 token 被按匿名处理)绝不落成「未收藏」**,按钮 title 标注「登录后可收藏」,点击走既有登录门禁且不发请求
- 乐观更新 + 回滚:先切换本地态,失败回滚到服务端投影并用服务端 message 提示(role=status);成功以服务端返回的 collected 为准
- 广场卡片:卡片本身没有操作区,按「没有就别硬塞」不动
- 我的收藏:落在 /games/mine 的页签(「我的作品」/「我的收藏」)。理由:两者都是登录后的个人内容、该页已有登录门禁;且不需要新路由,避免牵动「路由表 + nginx×3 + Pingora allowlist」这组必须同批登记的清单。复用公开目录同一套卡片(shared 的 GameCard + GameCover),空态/失败重试/登录态照既有写法
- 分页按「将来会有 cursor」的形态写:读 nextCursor,非空才渲染「加载更多」并在点击时追加;服务端当前恒为 null,不伪造分页数据
- 文案统一「收藏 / 已收藏」(不用需求文档旧词「收录」)
- 测试:详情页收藏四例(已收藏→取消 / 未收藏→收藏 / 缺字段走门禁且零请求且不渲染已收藏 / 失败回滚+服务端文案);我的收藏两例(空态且默认页签不预取 / 列表渲染+卡片点击跳详情+无 cursor 不出现「加载更多」)
This commit is contained in:
2026-10-06 01:18:45 +08:00
parent 4b1f24a8fc
commit 785152f70b
6 changed files with 458 additions and 1 deletions
@@ -22,6 +22,7 @@ import {
getGame,
getMyGame,
loadVersionRecoveryActions,
setGameCollection,
} from '../../services/gameDistributionClient';
import { useAuthUi } from '../auth/AuthUiContext';
import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
@@ -75,6 +76,14 @@ type GameDetailPageProps = {
ownerView?: boolean;
};
let collectionKeySeed = 0;
/** 每次收藏尝试一个幂等键:同一次尝试重试时键不变,重复点击则算新的意图。 */
function createCollectionKey(gameId: string) {
collectionKeySeed += 1;
return `game-collection:${gameId}:${collectionKeySeed}`;
}
/**
* 游玩方式优先取版本冻结时声明的输入方式(公开投影里的 `inputModes`),
* 再退回版本自由文本 `controls`;两者都没有才显示“未标注操作方式”。
@@ -371,6 +380,55 @@ function GameDetailContent({
? `/games/detail?id=${encodeURIComponent(game.id)}`
: `${window.location.origin}/games/detail?id=${encodeURIComponent(game.id)}`;
const canRemix = publiclyVisible && forkAuthorization !== 'forbidden';
// 收藏态:服务端只在**已认证**时返回 `collected`,匿名请求完全没有这个键。
// 因此「缺字段」一律按不可判定处理(未登录 / 过期 token 被按匿名处理),
// 绝不落成「未收藏」——否则未登录用户会看到一个假的已收藏/未收藏结论。
const [collectionOverride, setCollectionOverride] = useState<boolean | null>(
null,
);
const [isCollectionBusy, setIsCollectionBusy] = useState(false);
const [collectionNotice, setCollectionNotice] = useState('');
const serverCollected = game.collected;
const isCollected = collectionOverride ?? serverCollected ?? false;
const canKnowCollectionState =
typeof serverCollected === 'boolean' || collectionOverride !== null;
/**
* 收藏 / 取消收藏:先乐观切换,失败回滚到服务端给的值并提示错误文案。
* 未登录(或状态不可判定又未登录)走既有登录门禁,不伪造本地收藏态。
*/
async function toggleCollection() {
if (!isAuthenticated) {
onLogin();
return;
}
const next = !isCollected;
setIsCollectionBusy(true);
setCollectionNotice('');
setCollectionOverride(next);
try {
const result = await setGameCollection(
game.id,
next,
createCollectionKey(game.id),
);
// 以服务端返回为准(收藏可能已被别人/别的标签页改过)。
setCollectionOverride(result.collected);
} catch (updateError: unknown) {
// 回滚:清掉乐观值,重新以服务端投影为准。
setCollectionOverride(null);
setCollectionNotice(
updateError instanceof Error
? updateError.message
: next
? '收藏失败'
: '取消收藏失败',
);
} finally {
setIsCollectionBusy(false);
}
}
/**
* 清理唤起流程留下的定时器与失焦监听;组件卸载、重复点击、超时三条路径都走这里,
@@ -633,6 +691,33 @@ function GameDetailContent({
>
<Gamepad2 /> 立即玩
</PlatformActionButton>
{/*
收藏是玩家级动作:放在主行动作旁边(与「立即玩」同一区),而不是共创信息卡里
——那张卡讲的是「这部作品的改编关系」。未登录时不伪造收藏态:点击走登录门禁。
*/}
<PlatformActionButton
tone={isCollected ? 'primary' : 'secondary'}
size="sm"
shape="pill"
disabled={isCollectionBusy}
title={
isAuthenticated
? canKnowCollectionState
? isCollected
? '取消收藏'
: '收藏'
: '收藏状态未知,点击将收藏'
: '登录后可收藏'
}
onClick={() => void toggleCollection()}
>
{isCollected ? '已收藏' : '收藏'}
</PlatformActionButton>
{collectionNotice ? (
<span className="game-collection-notice" role="status">
{collectionNotice}
</span>
) : null}
{mobilePlaybackBlocked ? (
<span
id="game-mobile-playback-warning"
@@ -29,6 +29,7 @@ import { GamePlayPage } from './GamePlayPage';
import { resolveNonPublicCoverLabel } from './gameVersionHistoryModel';
const listGamesMock = vi.hoisted(() => vi.fn());
const setGameCollectionMock = vi.hoisted(() => vi.fn());
const getGameMock = vi.hoisted(() => vi.fn());
const getGameLineageMock = vi.hoisted(() => vi.fn());
const getMyGameMock = vi.hoisted(() => vi.fn());
@@ -53,6 +54,7 @@ vi.mock('../../services/creatorProfileClient', async (original) => ({
vi.mock('../../services/gameDistributionClient', () => ({
listGames: listGamesMock,
setGameCollection: setGameCollectionMock,
getGame: getGameMock,
getGameLineage: getGameLineageMock,
getMyGame: getMyGameMock,
@@ -178,6 +180,8 @@ beforeEach(() => {
copyTextToClipboardMock.mockReset();
copyTextToClipboardMock.mockResolvedValue(true);
openForkProtocolMock.mockReset();
setGameCollectionMock.mockReset();
setGameCollectionMock.mockResolvedValue({ collected: true });
setCreatorViewer(null);
getCreatorRelationshipMock.mockResolvedValue({
relationship: { isSelf: false, isFollowing: false, isFollowedBy: false },
@@ -1683,3 +1687,81 @@ describe('GameComparePage', () => {
expect(document.querySelectorAll('iframe.game-player-frame')).toHaveLength(1);
});
});
describe('GameDetailPage 收藏按钮', () => {
it('可判定为已收藏时显示「已收藏」,点击取消收藏', async () => {
getGameMock.mockResolvedValueOnce(createGame({ collected: true }));
setGameCollectionMock.mockResolvedValueOnce({ collected: false });
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
const button = await screen.findByRole('button', { name: '已收藏' });
fireEvent.click(button);
await waitFor(() =>
expect(setGameCollectionMock).toHaveBeenCalledWith(
'game-1',
false,
expect.stringContaining('game-1'),
),
);
// 以服务端返回为准:取消后回到「收藏」。
expect(await screen.findByRole('button', { name: '收藏' })).toBeTruthy();
});
it('可判定为未收藏时显示「收藏」,点击收藏并乐观切到已收藏', async () => {
getGameMock.mockResolvedValueOnce(createGame({ collected: false }));
setGameCollectionMock.mockResolvedValueOnce({ collected: true });
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
const button = await screen.findByRole('button', { name: '收藏' });
fireEvent.click(button);
await waitFor(() =>
expect(setGameCollectionMock).toHaveBeenCalledWith(
'game-1',
true,
expect.stringContaining('game-1'),
),
);
expect(await screen.findByRole('button', { name: '已收藏' })).toBeTruthy();
});
it('缺 collected 字段(匿名按不返回该键)时不渲染成未收藏,点击走登录门禁', async () => {
authUiMock.value.user = null;
authUiMock.value.canAccessProtectedData = false;
// 匿名响应完全没有 collected 键——不能当 false 渲染。
getGameMock.mockResolvedValueOnce(createGame());
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
const button = await screen.findByRole('button', { name: '收藏' });
expect(screen.queryByRole('button', { name: '已收藏' })).toBeNull();
expect(button.getAttribute('title')).toBe('登录后可收藏');
fireEvent.click(button);
expect(authUiMock.value.openLoginModal).toHaveBeenCalledTimes(1);
// 未登录不发收藏请求,也不伪造已收藏态。
expect(setGameCollectionMock).not.toHaveBeenCalled();
expect(screen.queryByRole('button', { name: '已收藏' })).toBeNull();
});
it('收藏失败时回滚乐观态并提示服务端文案', async () => {
getGameMock.mockResolvedValueOnce(createGame({ collected: false }));
setGameCollectionMock.mockRejectedValueOnce(new Error('作品未公开'));
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
fireEvent.click(await screen.findByRole('button', { name: '收藏' }));
expect(await screen.findByText('作品未公开')).toBeTruthy();
// 回滚:不能停在乐观的「已收藏」。
expect(screen.getByRole('button', { name: '收藏' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '已收藏' })).toBeNull();
});
});
@@ -11,6 +11,7 @@ import {
type GameDistributionMyGame,
getDerivedGames,
getGameVersion,
listMyCollections,
listMyGames,
loadVersionRecoveryActions,
submitGameVersion,
@@ -26,6 +27,7 @@ vi.mock('../../services/gameDistributionClient', () => ({
deleteMyGame: vi.fn(),
getDerivedGames: vi.fn(),
getGameVersion: vi.fn(),
listMyCollections: vi.fn(),
listMyGames: vi.fn(),
loadVersionRecoveryActions: vi.fn(),
submitGameVersion: vi.fn(),
@@ -116,6 +118,11 @@ beforeEach(() => {
vi.mocked(getDerivedGames).mockReset();
vi.mocked(getGameVersion).mockReset();
vi.mocked(listMyGames).mockReset();
vi.mocked(listMyCollections).mockReset();
vi.mocked(listMyCollections).mockResolvedValue({
games: [],
nextCursor: null,
});
vi.mocked(loadVersionRecoveryActions).mockReset();
vi.mocked(submitGameVersion).mockReset();
vi.mocked(unpublishGame).mockReset();
@@ -767,3 +774,47 @@ test('未公开作品不渲染「被改编 N」入口', async () => {
expect(screen.queryByRole('button', { name: /被改编/ })).toBeNull();
expect(getDerivedGames).not.toHaveBeenCalled();
});
test('「我的收藏」页签空收藏时给出空态,且只在切过去时拉取', async () => {
vi.mocked(listMyGames).mockResolvedValue([]);
vi.mocked(listMyCollections).mockResolvedValue({
games: [],
nextCursor: null,
});
renderPage(createAuthValue());
await screen.findByText('我的游戏');
// 默认在「我的作品」页签,不同时打收藏接口。
expect(listMyCollections).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('tab', { name: '我的收藏' }));
expect(
await screen.findByText(
'还没有收藏任何作品。在作品详情页点「收藏」,就会出现在这里。',
),
).toBeTruthy();
await waitFor(() => expect(listMyCollections).toHaveBeenCalledTimes(1));
});
test('「我的收藏」页签渲染收藏作品并可点进详情', async () => {
vi.mocked(listMyGames).mockResolvedValue([]);
vi.mocked(listMyCollections).mockResolvedValue({
games: [createGame({ id: 'game-c1', title: '被收藏的作品' })],
nextCursor: null,
});
const onOpenDetail = vi.fn();
render(
<AuthUiContext.Provider value={createAuthValue()}>
<MyGamesPage onBack={vi.fn()} onOpenDetail={onOpenDetail} />
</AuthUiContext.Provider>,
);
fireEvent.click(screen.getByRole('tab', { name: '我的收藏' }));
const title = await screen.findByText('被收藏的作品');
fireEvent.click(title);
expect(onOpenDetail).toHaveBeenCalledWith('game-c1');
// 服务端当前 nextCursor 恒为 null:不渲染「加载更多」。
expect(screen.queryByRole('button', { name: '加载更多' })).toBeNull();
});
@@ -1,5 +1,6 @@
import './gameDistribution.css';
import { GameCard } from '@genarrative/shared/components';
import { ArrowLeft, RefreshCcw } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
@@ -15,11 +16,13 @@ import {
import {
cancelGameVersion,
deleteMyGame,
type GameDistributionGame,
type GameDistributionLineageNode,
type GameDistributionMyGame,
type GameDistributionUpdateMyGameResponse,
type GameDistributionVersionStatusEntry,
getDerivedGames,
listMyCollections,
listMyGames,
loadVersionRecoveryActions,
submitGameVersion,
@@ -209,6 +212,21 @@ export function MyGamesPage({
>({});
const [notice, setNotice] = useState('');
const [error, setError] = useState('');
/**
* 页签:作品管理(原有内容)与「我的收藏」。
*
* 放在 `/games/mine` 而不是「我的」个人页:两者都是「登录后属于我自己的东西」,
* 且这一页已经是登录门禁后的作者中心,收藏列表不需要新路由(也就不会牵动
* 路由表 / nginx / Pingora 那一组必须同批登记的清单)。收藏是玩家级动作,
* 不要求作者身份,门禁只要求登录,与这一页的既有门禁一致。
*/
const [tab, setTab] = useState<'works' | 'collections'>('works');
const [collections, setCollections] = useState<GameDistributionGame[]>([]);
const [isLoadingCollections, setIsLoadingCollections] = useState(false);
const [collectionsError, setCollectionsError] = useState('');
const [collectionsNextCursor, setCollectionsNextCursor] = useState<
string | null
>(null);
const canLoad = Boolean(authUi?.user && authUi.canAccessProtectedData);
const currentUserId = authUi?.user?.id ?? '';
/**
@@ -264,6 +282,40 @@ export function MyGamesPage({
void loadGames();
}, [loadGames]);
/**
* 「我的收藏」:只在切到该页签时按需拉取,避免每次进我的作品都多打一次接口。
* 游标形态按「将来会有 cursor」写:传 cursor 时追加,否则替换;服务端当前恒为 null,
* 所以现在不会出现「加载更多」,但接口形状已经兼容。
*/
const loadCollections = useCallback(
async (cursor?: string) => {
if (!canLoad) {
setCollections([]);
return;
}
setIsLoadingCollections(true);
setCollectionsError('');
try {
const page = await listMyCollections();
setCollections((current) =>
cursor ? [...current, ...page.games] : page.games,
);
setCollectionsNextCursor(page.nextCursor);
} catch (loadError: unknown) {
setCollectionsError(
loadError instanceof Error ? loadError.message : '读取我的收藏失败',
);
} finally {
setIsLoadingCollections(false);
}
},
[canLoad],
);
useEffect(() => {
if (tab === 'collections') void loadCollections();
}, [loadCollections, tab]);
const visibleGames = useMemo(
() =>
games.filter((game) => {
@@ -501,6 +553,98 @@ export function MyGamesPage({
) : null}
</div>
<div className="game-tabs" role="tablist" aria-label="我的内容">
<button
type="button"
role="tab"
aria-selected={tab === 'works'}
className="game-tabs__button"
onClick={() => setTab('works')}
>
我的作品
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'collections'}
className="game-tabs__button"
onClick={() => setTab('collections')}
>
我的收藏
</button>
</div>
{tab === 'collections' ? (
<>
{collectionsError ? (
<PlatformStatusMessage tone="error" surface="platform">
<span>{collectionsError}</span>
<button
type="button"
className="game-lineage-retry"
onClick={() => void loadCollections()}
>
重试
</button>
</PlatformStatusMessage>
) : null}
{!canLoad ? (
<PlatformEmptyState surface="subpanel" size="panel">
<span>登录后可以查看收藏的作品。</span>
<PlatformActionButton
size="sm"
shape="pill"
onClick={() => authUi?.openLoginModal(() => void loadCollections())}
>
登录
</PlatformActionButton>
</PlatformEmptyState>
) : null}
{canLoad && isLoadingCollections ? (
<div className="game-loading-panel">正在加载我的收藏…</div>
) : null}
{canLoad &&
!isLoadingCollections &&
!collectionsError &&
collections.length === 0 ? (
<PlatformEmptyState surface="subpanel" size="panel">
<span>
还没有收藏任何作品。在作品详情页点「收藏」,就会出现在这里。
</span>
</PlatformEmptyState>
) : null}
{canLoad && collections.length > 0 ? (
<>
<div className="game-grid">
{collections.map((game) => (
<GameCard
key={game.id}
cover={<GameCover game={game} />}
game={game}
onOpen={() => onOpenDetail(game.id)}
/>
))}
</div>
{collectionsNextCursor ? (
<div className="game-empty-actions">
<PlatformActionButton
tone="secondary"
size="sm"
shape="pill"
disabled={isLoadingCollections}
onClick={() => void loadCollections(collectionsNextCursor)}
>
加载更多
</PlatformActionButton>
</div>
) : null}
</>
) : null}
</>
) : null}
{tab === 'works' ? (
<>
{error ? (
<PlatformStatusMessage tone="error" surface="platform">
{error}
@@ -1006,6 +1150,9 @@ export function MyGamesPage({
</div>
) : null}
</>
) : null}
{editingGame ? (
<GameWorkMetadataEditor
key={editingGame.id}
@@ -527,6 +527,34 @@
.game-detail-info-card .game-fork-compare-link:hover {
color: var(--platform-text-strong);
}
/* 收藏失败/状态提示:与动作行同一行内换行,不撑破容器。 */
.game-collection-notice {
color: var(--platform-text-soft);
font-size: 0.78rem;
overflow-wrap: anywhere;
}
/* 我的作品 / 我的收藏页签。 */
.game-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.game-tabs__button {
border: 1px solid var(--platform-surface-border);
border-radius: 999px;
padding: 0.25rem 0.9rem;
background: transparent;
color: var(--platform-text-base);
font: inherit;
font-size: 0.8rem;
font-weight: 700;
cursor: pointer;
}
.game-tabs__button[aria-selected='true'] {
border-color: var(--platform-accent-strong);
background: rgba(199, 101, 61, 0.12);
color: var(--platform-accent-strong);
}
.game-lineage-node__compare {
align-self: flex-start;
border: 1px solid var(--platform-surface-border);
+65 -1
View File
@@ -357,7 +357,7 @@ export async function updateGameForkAuthorization(
export async function getGame(gameId: string) {
const normalizedId = gameId.trim();
if (!normalizedId) return null;
return requestJson<GameDistributionGame>(
return requestJson<GameDistributionGameWithCollection>(
`/games/${encodeURIComponent(normalizedId)}`,
{ method: 'GET' },
'读取游戏详情失败',
@@ -365,6 +365,70 @@ export async function getGame(gameId: string) {
);
}
/**
* 公开详情在**已认证**时多带 `collected`;匿名请求**完全不返回该键**。
*
* 所以类型里它是可选的,读侧必须把「缺字段」与 `false` 区分开:缺字段 = 不可判定
* (未登录,或带过期 token 被服务端按匿名处理),不能渲染成「未收藏」。
*/
export type GameDistributionGameWithCollection = GameDistributionGame & {
collected?: boolean;
};
/** 收藏切换的响应;DELETE 不带 `replayed`。 */
export type GameDistributionCollectionState = {
collected: boolean;
replayed?: boolean;
};
/**
* 收藏 / 取消收藏。收藏走 PUT(幂等键由调用方生成,重复提交返回同一结果);
* 取消走 DELETE(确定性主键,重复调用结果相同,因此不需要幂等键)。
*/
export async function setGameCollection(
gameId: string,
collected: boolean,
idempotencyKey: string,
) {
const normalizedId = gameId.trim();
if (!normalizedId) throw new Error('收藏缺少作品编号');
if (!collected) {
return requestJson<GameDistributionCollectionState>(
`/games/${encodeURIComponent(normalizedId)}/collection`,
{ method: 'DELETE' },
'取消收藏失败',
);
}
const normalizedKey = idempotencyKey.trim();
if (!normalizedKey) throw new Error('收藏缺少幂等键');
return requestJson<GameDistributionCollectionState>(
`/games/${encodeURIComponent(normalizedId)}/collection`,
{
method: 'PUT',
headers: { 'Idempotency-Key': normalizedKey },
},
'收藏失败',
);
}
/**
* 「我的收藏」:只返回当前公开可读的收藏作品(下架/软删除的作品只是不出现,行不删除),
* 条目形状与公开目录逐字一致,因此可以复用同一套卡片组件渲染。
*
* `nextCursor` 服务端当前恒为 `null`(真分页是已知留白);这里按「将来会有 cursor」的形态
* 读出来并原样交给调用方,调用方只在非空时渲染「加载更多」。
*/
export async function listMyCollections() {
const payload = await requestJson<{
games?: unknown;
nextCursor?: string | null;
}>('/my-collections', { method: 'GET', cache: 'no-store' }, '读取我的收藏失败');
return {
games: normalizeGameList(payload.games),
nextCursor: payload.nextCursor ?? null,
};
}
/**
* 读取以某作品的根为顶的创作族谱树。公开只读接口,匿名可读;
* 作品不存在或已软删除时服务端返回 404,这里按失败抛出,由页面走既有错误态。