From 785152f70bbfe8cd5ca3c420e111c5767be55532 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 6 Oct 2026 01:18:45 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BD=91=E9=A1=B5=E7=AB=AF=E6=94=B6=E8=97=8F?= =?UTF-8?q?=EF=BC=9A=E4=BD=9C=E5=93=81=E8=AF=A6=E6=83=85=E9=A1=B5=E6=94=B6?= =?UTF-8?q?=E8=97=8F=E5=88=87=E6=8D=A2=20+=20=E6=88=91=E7=9A=84=E6=94=B6?= =?UTF-8?q?=E8=97=8F=E9=A1=B5=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 客户端:新增 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 不出现「加载更多」) --- .../game-distribution/GameDetailPage.tsx | 85 ++++++++++ .../GameDistributionPages.test.tsx | 82 ++++++++++ .../game-distribution/MyGamesPage.test.tsx | 51 ++++++ .../game-distribution/MyGamesPage.tsx | 147 ++++++++++++++++++ .../game-distribution/gameDistribution.css | 28 ++++ src/services/gameDistributionClient.ts | 66 +++++++- 6 files changed, 458 insertions(+), 1 deletion(-) diff --git a/src/components/game-distribution/GameDetailPage.tsx b/src/components/game-distribution/GameDetailPage.tsx index 1318b05b1..447d080ee 100644 --- a/src/components/game-distribution/GameDetailPage.tsx +++ b/src/components/game-distribution/GameDetailPage.tsx @@ -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( + 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({ > 立即玩 + {/* + 收藏是玩家级动作:放在主行动作旁边(与「立即玩」同一区),而不是共创信息卡里 + ——那张卡讲的是「这部作品的改编关系」。未登录时不伪造收藏态:点击走登录门禁。 + */} + void toggleCollection()} + > + {isCollected ? '已收藏' : '收藏'} + + {collectionNotice ? ( + + {collectionNotice} + + ) : null} {mobilePlaybackBlocked ? ( 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + fireEvent.click(await screen.findByRole('button', { name: '收藏' })); + + expect(await screen.findByText('作品未公开')).toBeTruthy(); + // 回滚:不能停在乐观的「已收藏」。 + expect(screen.getByRole('button', { name: '收藏' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: '已收藏' })).toBeNull(); + }); +}); diff --git a/src/components/game-distribution/MyGamesPage.test.tsx b/src/components/game-distribution/MyGamesPage.test.tsx index 93e23939a..57090c7b5 100644 --- a/src/components/game-distribution/MyGamesPage.test.tsx +++ b/src/components/game-distribution/MyGamesPage.test.tsx @@ -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( + + + , + ); + 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(); +}); diff --git a/src/components/game-distribution/MyGamesPage.tsx b/src/components/game-distribution/MyGamesPage.tsx index 31b7eb3dc..a0e9d2de6 100644 --- a/src/components/game-distribution/MyGamesPage.tsx +++ b/src/components/game-distribution/MyGamesPage.tsx @@ -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([]); + 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} +
+ + +
+ + {tab === 'collections' ? ( + <> + {collectionsError ? ( + + {collectionsError} + + + ) : null} + {!canLoad ? ( + + 登录后可以查看收藏的作品。 + authUi?.openLoginModal(() => void loadCollections())} + > + 登录 + + + ) : null} + {canLoad && isLoadingCollections ? ( +
正在加载我的收藏…
+ ) : null} + {canLoad && + !isLoadingCollections && + !collectionsError && + collections.length === 0 ? ( + + + 还没有收藏任何作品。在作品详情页点「收藏」,就会出现在这里。 + + + ) : null} + {canLoad && collections.length > 0 ? ( + <> +
+ {collections.map((game) => ( + } + game={game} + onOpen={() => onOpenDetail(game.id)} + /> + ))} +
+ {collectionsNextCursor ? ( +
+ void loadCollections(collectionsNextCursor)} + > + 加载更多 + +
+ ) : null} + + ) : null} + + ) : null} + + {tab === 'works' ? ( + <> {error ? ( {error} @@ -1006,6 +1150,9 @@ export function MyGamesPage({ ) : null} + + ) : null} + {editingGame ? ( ( + return requestJson( `/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( + `/games/${encodeURIComponent(normalizedId)}/collection`, + { method: 'DELETE' }, + '取消收藏失败', + ); + } + const normalizedKey = idempotencyKey.trim(); + if (!normalizedKey) throw new Error('收藏缺少幂等键'); + return requestJson( + `/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,这里按失败抛出,由页面走既有错误态。