网页端收藏:作品详情页收藏切换 + 我的收藏页签
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
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:
@@ -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);
|
||||
|
||||
@@ -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,这里按失败抛出,由页面走既有错误态。
|
||||
|
||||
Reference in New Issue
Block a user