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